Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5809ee0af7 | ||
|
|
159e91248c |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -2,12 +2,6 @@
|
|||||||
Django settings for config project.
|
Django settings for config project.
|
||||||
|
|
||||||
Generated by 'django-admin startproject' using Django 5.2.7.
|
Generated by 'django-admin startproject' using Django 5.2.7.
|
||||||
|
|
||||||
For more information on this file, see
|
|
||||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
|
||||||
|
|
||||||
For the full list of settings and their values, see
|
|
||||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -15,38 +9,32 @@ import os
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
load_dotenv(BASE_DIR.parent / ".env")
|
load_dotenv(BASE_DIR.parent / '.env')
|
||||||
|
|
||||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'change-me')
|
||||||
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
DEBUG = os.getenv('DJANGO_DEBUG', 'true').lower() == 'true'
|
||||||
|
|
||||||
ALLOWED_HOSTS = [
|
ALLOWED_HOSTS = [
|
||||||
"127.0.0.1",
|
'127.0.0.1',
|
||||||
"localhost",
|
'localhost',
|
||||||
os.getenv("HOST_FQDN", ""),
|
os.getenv('HOST_FQDN', ''),
|
||||||
]
|
]
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
origin for origin in [
|
origin for origin in [
|
||||||
os.getenv("HOST_FQDN", ""),
|
os.getenv('HOST_FQDN', ''),
|
||||||
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
os.getenv('CSRF_TRUSTED_ORIGIN', ''),
|
||||||
] if origin
|
] if origin
|
||||||
]
|
]
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
f"https://{host}" if not host.startswith(("http://", "https://")) else host
|
f'https://{host}' if not host.startswith(('http://', 'https://')) else host
|
||||||
for host in CSRF_TRUSTED_ORIGINS
|
for host in CSRF_TRUSTED_ORIGINS
|
||||||
]
|
]
|
||||||
|
|
||||||
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
|
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
CSRF_COOKIE_SECURE = True
|
CSRF_COOKIE_SECURE = True
|
||||||
SESSION_COOKIE_SAMESITE = "None"
|
SESSION_COOKIE_SAMESITE = 'None'
|
||||||
CSRF_COOKIE_SAMESITE = "None"
|
CSRF_COOKIE_SAMESITE = 'None'
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
|
||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
|
||||||
|
|
||||||
# Application definition
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
@ -65,8 +53,6 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
|
|
||||||
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
X_FRAME_OPTIONS = 'ALLOWALL'
|
X_FRAME_OPTIONS = 'ALLOWALL'
|
||||||
@ -83,7 +69,6 @@ TEMPLATES = [
|
|||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
# IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
|
|
||||||
'core.context_processors.project_context',
|
'core.context_processors.project_context',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -92,10 +77,6 @@ TEMPLATES = [
|
|||||||
|
|
||||||
WSGI_APPLICATION = 'config.wsgi.application'
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
# Database
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.mysql',
|
'ENGINE': 'django.db.backends.mysql',
|
||||||
@ -110,73 +91,48 @@ DATABASES = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||||
},
|
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||||
{
|
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
|
||||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
|
||||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = 'static/'
|
||||||
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
|
|
||||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
STATICFILES_DIRS = [
|
||||||
BASE_DIR / 'static',
|
BASE_DIR / 'static',
|
||||||
BASE_DIR / 'assets',
|
BASE_DIR / 'assets',
|
||||||
BASE_DIR / 'node_modules',
|
BASE_DIR / 'node_modules',
|
||||||
]
|
]
|
||||||
|
MEDIA_URL = 'media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
# Email
|
EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend')
|
||||||
EMAIL_BACKEND = os.getenv(
|
EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1')
|
||||||
"EMAIL_BACKEND",
|
EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587'))
|
||||||
"django.core.mail.backends.smtp.EmailBackend"
|
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
|
||||||
)
|
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
|
||||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1")
|
EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
|
||||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() == 'true'
|
||||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
|
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@example.com')
|
||||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
|
|
||||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
|
||||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
|
|
||||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com")
|
|
||||||
CONTACT_EMAIL_TO = [
|
CONTACT_EMAIL_TO = [
|
||||||
item.strip()
|
item.strip()
|
||||||
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
|
for item in os.getenv('CONTACT_EMAIL_TO', DEFAULT_FROM_EMAIL).split(',')
|
||||||
if item.strip()
|
if item.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
# When both TLS and SSL flags are enabled, prefer SSL explicitly
|
|
||||||
if EMAIL_USE_SSL:
|
if EMAIL_USE_SSL:
|
||||||
EMAIL_USE_TLS = False
|
EMAIL_USE_TLS = False
|
||||||
# Default primary key field type
|
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
LOGIN_URL = 'login'
|
||||||
|
LOGIN_REDIRECT_URL = 'dashboard'
|
||||||
|
LOGOUT_REDIRECT_URL = 'home'
|
||||||
|
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|||||||
@ -1,29 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
URL configuration for config project.
|
URL configuration for config project.
|
||||||
|
|
||||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
|
||||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
|
||||||
Examples:
|
|
||||||
Function views
|
|
||||||
1. Add an import: from my_app import views
|
|
||||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
|
||||||
Class-based views
|
|
||||||
1. Add an import: from other_app.views import Home
|
|
||||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
|
||||||
Including another URLconf
|
|
||||||
1. Import the include() function: from django.urls import include, path
|
|
||||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
|
||||||
"""
|
"""
|
||||||
from django.contrib import admin
|
|
||||||
from django.urls import include, path
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path("", include("core.urls")),
|
path('', include('core.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
urlpatterns += static('/assets/', document_root=settings.BASE_DIR / 'assets')
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,59 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
# Register your models here.
|
from .models import Business, BusinessMembership, Customer, Feedback, Job, JobMedia, ProofCard, ReviewRequest
|
||||||
|
|
||||||
|
|
||||||
|
class JobMediaInline(admin.TabularInline):
|
||||||
|
model = JobMedia
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Business)
|
||||||
|
class BusinessAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'industry', 'primary_city', 'primary_state', 'is_active')
|
||||||
|
list_filter = ('industry', 'is_active')
|
||||||
|
search_fields = ('name', 'slug', 'primary_city')
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BusinessMembership)
|
||||||
|
class BusinessMembershipAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('user', 'business', 'role', 'created_at')
|
||||||
|
list_filter = ('role', 'business')
|
||||||
|
search_fields = ('user__email', 'user__first_name', 'user__last_name', 'business__name')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Customer)
|
||||||
|
class CustomerAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('full_name', 'business', 'city', 'state', 'email', 'phone')
|
||||||
|
list_filter = ('business', 'state')
|
||||||
|
search_fields = ('full_name', 'email', 'phone')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Job)
|
||||||
|
class JobAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('service_type', 'business', 'customer', 'city', 'state', 'completed_at', 'status')
|
||||||
|
list_filter = ('business', 'status', 'state', 'completed_at')
|
||||||
|
search_fields = ('service_type', 'customer__full_name', 'technician_name')
|
||||||
|
inlines = [JobMediaInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ReviewRequest)
|
||||||
|
class ReviewRequestAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('job', 'channel', 'status', 'sent_at', 'reviewed_at')
|
||||||
|
list_filter = ('channel', 'status')
|
||||||
|
search_fields = ('job__customer__full_name', 'job__service_type')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Feedback)
|
||||||
|
class FeedbackAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('review_request', 'experience', 'rating', 'follow_up_required', 'is_public_approved', 'created_at')
|
||||||
|
list_filter = ('experience', 'follow_up_required', 'is_public_approved')
|
||||||
|
search_fields = ('review_request__job__customer__full_name', 'testimonial')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ProofCard)
|
||||||
|
class ProofCardAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('job', 'customer_display_name', 'status', 'is_featured', 'rating', 'published_at')
|
||||||
|
list_filter = ('status', 'is_featured')
|
||||||
|
search_fields = ('customer_display_name', 'job__service_type', 'testimonial_quote')
|
||||||
|
|||||||
@ -1,13 +1,33 @@
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from .models import BusinessMembership
|
||||||
|
|
||||||
|
ACTIVE_BUSINESS_SESSION_KEY = 'trustforge_active_business_id'
|
||||||
|
|
||||||
|
|
||||||
def project_context(request):
|
def project_context(request):
|
||||||
"""
|
"""
|
||||||
Adds project-specific environment variables to the template context globally.
|
Adds project-specific environment variables and active workspace context globally.
|
||||||
"""
|
"""
|
||||||
|
current_membership = None
|
||||||
|
memberships = []
|
||||||
|
if getattr(request, 'user', None) and request.user.is_authenticated:
|
||||||
|
memberships = list(
|
||||||
|
BusinessMembership.objects.select_related('business').filter(
|
||||||
|
user=request.user,
|
||||||
|
business__is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
active_business_id = request.session.get(ACTIVE_BUSINESS_SESSION_KEY)
|
||||||
|
current_membership = next((item for item in memberships if item.business_id == active_business_id), None)
|
||||||
|
if current_membership is None and memberships:
|
||||||
|
current_membership = memberships[0]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
'project_description': os.getenv('PROJECT_DESCRIPTION', ''),
|
||||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
'project_image_url': os.getenv('PROJECT_IMAGE_URL', ''),
|
||||||
# Used for cache-busting static assets
|
'deployment_timestamp': int(time.time()),
|
||||||
"deployment_timestamp": int(time.time()),
|
'current_membership': current_membership,
|
||||||
|
'user_memberships': memberships,
|
||||||
}
|
}
|
||||||
|
|||||||
372
core/forms.py
Normal file
372
core/forms.py
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.contrib.auth import authenticate, get_user_model
|
||||||
|
from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, SetPasswordForm, UserCreationForm
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import Business, BusinessMembership, Feedback, ProofCard, ReviewRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class TrustForgeAuthenticationForm(AuthenticationForm):
|
||||||
|
username = forms.CharField(
|
||||||
|
label='Work email',
|
||||||
|
widget=forms.EmailInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
'placeholder': 'you@company.com',
|
||||||
|
'autocomplete': 'email',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
password = forms.CharField(
|
||||||
|
label='Password',
|
||||||
|
strip=False,
|
||||||
|
widget=forms.PasswordInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
'placeholder': 'Enter your password',
|
||||||
|
'autocomplete': 'current-password',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
email = (self.cleaned_data.get('username') or '').strip().lower()
|
||||||
|
password = self.cleaned_data.get('password')
|
||||||
|
|
||||||
|
if email and password:
|
||||||
|
matched_user = User._default_manager.filter(email__iexact=email).first()
|
||||||
|
auth_username = matched_user.get_username() if matched_user else email
|
||||||
|
self.user_cache = authenticate(self.request, username=auth_username, password=password)
|
||||||
|
if self.user_cache is None:
|
||||||
|
raise self.get_invalid_login_error()
|
||||||
|
self.confirm_login_allowed(self.user_cache)
|
||||||
|
|
||||||
|
return self.cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class SignUpForm(UserCreationForm):
|
||||||
|
first_name = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
max_length=150,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Avery'}),
|
||||||
|
)
|
||||||
|
last_name = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
max_length=150,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Stone'}),
|
||||||
|
)
|
||||||
|
email = forms.EmailField(
|
||||||
|
widget=forms.EmailInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
'placeholder': 'owner@servicebrand.com',
|
||||||
|
'autocomplete': 'email',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
password1 = forms.CharField(
|
||||||
|
label='Password',
|
||||||
|
strip=False,
|
||||||
|
widget=forms.PasswordInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
'placeholder': 'Create a password',
|
||||||
|
'autocomplete': 'new-password',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
password2 = forms.CharField(
|
||||||
|
label='Confirm password',
|
||||||
|
strip=False,
|
||||||
|
widget=forms.PasswordInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
'placeholder': 'Repeat your password',
|
||||||
|
'autocomplete': 'new-password',
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(UserCreationForm.Meta):
|
||||||
|
model = User
|
||||||
|
fields = ('first_name', 'last_name', 'email', 'password1', 'password2')
|
||||||
|
|
||||||
|
def clean_email(self):
|
||||||
|
email = (self.cleaned_data.get('email') or '').strip().lower()
|
||||||
|
if User._default_manager.filter(email__iexact=email).exists() or User._default_manager.filter(username__iexact=email).exists():
|
||||||
|
raise forms.ValidationError('An account with this email already exists.')
|
||||||
|
return email
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
user = super().save(commit=False)
|
||||||
|
email = self.cleaned_data['email']
|
||||||
|
user.username = email
|
||||||
|
user.email = email
|
||||||
|
user.first_name = self.cleaned_data.get('first_name', '').strip()
|
||||||
|
user.last_name = self.cleaned_data.get('last_name', '').strip()
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileSettingsForm(forms.ModelForm):
|
||||||
|
first_name = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
max_length=150,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Avery'}),
|
||||||
|
)
|
||||||
|
last_name = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
max_length=150,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Stone'}),
|
||||||
|
)
|
||||||
|
email = forms.EmailField(
|
||||||
|
widget=forms.EmailInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
'placeholder': 'you@company.com',
|
||||||
|
'autocomplete': 'email',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ('first_name', 'last_name', 'email')
|
||||||
|
|
||||||
|
def clean_email(self):
|
||||||
|
email = (self.cleaned_data.get('email') or '').strip().lower()
|
||||||
|
email_exists = User._default_manager.filter(email__iexact=email).exclude(pk=self.instance.pk).exists()
|
||||||
|
username_exists = User._default_manager.filter(username__iexact=email).exclude(pk=self.instance.pk).exists()
|
||||||
|
if email_exists or username_exists:
|
||||||
|
raise forms.ValidationError('Another account already uses this email.')
|
||||||
|
return email
|
||||||
|
|
||||||
|
def save(self, commit=True):
|
||||||
|
user = super().save(commit=False)
|
||||||
|
email = self.cleaned_data['email']
|
||||||
|
user.email = email
|
||||||
|
user.username = email
|
||||||
|
user.first_name = self.cleaned_data.get('first_name', '').strip()
|
||||||
|
user.last_name = self.cleaned_data.get('last_name', '').strip()
|
||||||
|
if commit:
|
||||||
|
user.save()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class TrustForgePasswordResetForm(PasswordResetForm):
|
||||||
|
email = forms.EmailField(
|
||||||
|
widget=forms.EmailInput(
|
||||||
|
attrs={
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
'placeholder': 'you@company.com',
|
||||||
|
'autocomplete': 'email',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_mail(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return super().send_mail(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
logger.exception('Password reset email failed to send.')
|
||||||
|
|
||||||
|
|
||||||
|
class TrustForgeSetPasswordForm(SetPasswordForm):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['new_password1'].widget.attrs.update(
|
||||||
|
{
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
'placeholder': 'Create a new password',
|
||||||
|
'autocomplete': 'new-password',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.fields['new_password2'].widget.attrs.update(
|
||||||
|
{
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
'placeholder': 'Confirm the new password',
|
||||||
|
'autocomplete': 'new-password',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessOnboardingForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Business
|
||||||
|
fields = ('name', 'industry', 'primary_city', 'primary_state', 'google_review_url')
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Summit Home Services'}),
|
||||||
|
'industry': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'HVAC, Roofing, Plumbing, Junk Removal…'}),
|
||||||
|
'primary_city': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Austin'}),
|
||||||
|
'primary_state': forms.TextInput(attrs={'class': 'form-control text-uppercase', 'placeholder': 'TX'}),
|
||||||
|
'google_review_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://g.page/r/.../review'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean_primary_state(self):
|
||||||
|
return (self.cleaned_data.get('primary_state') or '').upper()
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessSettingsForm(BusinessOnboardingForm):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMemberInviteForm(forms.Form):
|
||||||
|
first_name = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
max_length=150,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Jamie'}),
|
||||||
|
)
|
||||||
|
last_name = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
max_length=150,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Rivera'}),
|
||||||
|
)
|
||||||
|
email = forms.EmailField(
|
||||||
|
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tech@servicebrand.com'})
|
||||||
|
)
|
||||||
|
role = forms.ChoiceField(
|
||||||
|
choices=BusinessMembership.Role.choices,
|
||||||
|
initial=BusinessMembership.Role.TECHNICIAN,
|
||||||
|
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_email(self):
|
||||||
|
return (self.cleaned_data.get('email') or '').strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
class JobIntakeForm(forms.Form):
|
||||||
|
business = forms.ModelChoiceField(
|
||||||
|
queryset=Business.objects.filter(is_active=True),
|
||||||
|
empty_label=None,
|
||||||
|
widget=forms.Select(attrs={'class': 'form-select form-control-lg'}),
|
||||||
|
)
|
||||||
|
customer_name = forms.CharField(
|
||||||
|
max_length=160,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Homeowner or business contact'}),
|
||||||
|
)
|
||||||
|
customer_email = forms.EmailField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'customer@example.com'}),
|
||||||
|
)
|
||||||
|
customer_phone = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
max_length=40,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '(555) 555-0123'}),
|
||||||
|
)
|
||||||
|
service_type = forms.CharField(
|
||||||
|
max_length=120,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Roof replacement, HVAC tune-up, junk removal…'}),
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'What did the crew complete on-site?'}),
|
||||||
|
)
|
||||||
|
customer_city = forms.CharField(
|
||||||
|
max_length=120,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Austin'}),
|
||||||
|
)
|
||||||
|
customer_state = forms.CharField(
|
||||||
|
max_length=2,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control text-uppercase', 'placeholder': 'TX'}),
|
||||||
|
)
|
||||||
|
technician_name = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
max_length=120,
|
||||||
|
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Luis R.'}),
|
||||||
|
)
|
||||||
|
completion_date = forms.DateField(
|
||||||
|
initial=timezone.localdate,
|
||||||
|
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||||
|
)
|
||||||
|
project_value = forms.DecimalField(
|
||||||
|
required=False,
|
||||||
|
min_value=0,
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '4500'}),
|
||||||
|
)
|
||||||
|
before_photo = forms.FileField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'}),
|
||||||
|
)
|
||||||
|
after_photo = forms.FileField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'}),
|
||||||
|
)
|
||||||
|
anonymize_customer = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
initial=True,
|
||||||
|
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
)
|
||||||
|
send_review_request = forms.BooleanField(
|
||||||
|
required=False,
|
||||||
|
initial=True,
|
||||||
|
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
)
|
||||||
|
review_channel = forms.ChoiceField(
|
||||||
|
choices=ReviewRequest.Channel.choices,
|
||||||
|
initial=ReviewRequest.Channel.EMAIL,
|
||||||
|
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, business: Business | None = None, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if business is not None:
|
||||||
|
self.fields['business'].queryset = Business.objects.filter(pk=business.pk)
|
||||||
|
self.fields['business'].initial = business
|
||||||
|
self.fields['business'].widget = forms.HiddenInput()
|
||||||
|
|
||||||
|
def clean_customer_state(self):
|
||||||
|
return self.cleaned_data['customer_state'].upper()
|
||||||
|
|
||||||
|
|
||||||
|
class PublicFeedbackForm(forms.Form):
|
||||||
|
experience = forms.ChoiceField(
|
||||||
|
choices=Feedback.Experience.choices,
|
||||||
|
widget=forms.RadioSelect,
|
||||||
|
)
|
||||||
|
testimonial = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'Tell us what stood out about the service…'}),
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
cleaned_data = super().clean()
|
||||||
|
experience = cleaned_data.get('experience')
|
||||||
|
testimonial = (cleaned_data.get('testimonial') or '').strip()
|
||||||
|
if experience in {Feedback.Experience.GREAT, Feedback.Experience.GOOD} and len(testimonial) < 12:
|
||||||
|
self.add_error('testimonial', 'For positive feedback, add a short testimonial so it can power the proof card.')
|
||||||
|
return cleaned_data
|
||||||
|
|
||||||
|
|
||||||
|
class ProofCardForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ProofCard
|
||||||
|
fields = [
|
||||||
|
'customer_display_name',
|
||||||
|
'is_anonymized',
|
||||||
|
'testimonial_quote',
|
||||||
|
'rating',
|
||||||
|
'status',
|
||||||
|
'is_featured',
|
||||||
|
'attached_widget_label',
|
||||||
|
'attached_pages',
|
||||||
|
]
|
||||||
|
widgets = {
|
||||||
|
'customer_display_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'is_anonymized': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
'testimonial_quote': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
||||||
|
'rating': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 5}),
|
||||||
|
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||||
|
'is_featured': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||||
|
'attached_widget_label': forms.TextInput(attrs={'class': 'form-control'}),
|
||||||
|
'attached_pages': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||||
|
}
|
||||||
143
core/migrations/0001_initial.py
Normal file
143
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-04-11 01:16
|
||||||
|
|
||||||
|
import core.models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Business',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=160)),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
('industry', models.CharField(default='Home Services', max_length=120)),
|
||||||
|
('primary_city', models.CharField(max_length=120)),
|
||||||
|
('primary_state', models.CharField(max_length=2)),
|
||||||
|
('google_review_url', models.URLField(blank=True)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'businesses',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Customer',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('full_name', models.CharField(max_length=160)),
|
||||||
|
('email', models.EmailField(blank=True, max_length=254)),
|
||||||
|
('phone', models.CharField(blank=True, max_length=40)),
|
||||||
|
('city', models.CharField(max_length=120)),
|
||||||
|
('state', models.CharField(max_length=2)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='core.business')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['full_name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Job',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('service_type', models.CharField(max_length=120)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('technician_name', models.CharField(blank=True, max_length=120)),
|
||||||
|
('city', models.CharField(max_length=120)),
|
||||||
|
('state', models.CharField(max_length=2)),
|
||||||
|
('completed_at', models.DateField(default=django.utils.timezone.localdate)),
|
||||||
|
('project_value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||||
|
('status', models.CharField(choices=[('completed', 'Completed'), ('review_requested', 'Review requested'), ('proof_ready', 'Proof ready')], default='completed', max_length=32)),
|
||||||
|
('is_verified', models.BooleanField(default=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='core.business')),
|
||||||
|
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='core.customer')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-completed_at', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='JobMedia',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('media_type', models.CharField(choices=[('before', 'Before'), ('after', 'After')], max_length=16)),
|
||||||
|
('file', models.FileField(blank=True, upload_to=core.models.job_media_upload_path)),
|
||||||
|
('caption', models.CharField(blank=True, max_length=140)),
|
||||||
|
('display_order', models.PositiveSmallIntegerField(default=0)),
|
||||||
|
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media', to='core.job')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name_plural': 'job media',
|
||||||
|
'ordering': ['display_order', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ProofCard',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('customer_display_name', models.CharField(max_length=160)),
|
||||||
|
('is_anonymized', models.BooleanField(default=False)),
|
||||||
|
('testimonial_quote', models.TextField(blank=True)),
|
||||||
|
('rating', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||||
|
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('hidden', 'Hidden')], default='draft', max_length=16)),
|
||||||
|
('is_featured', models.BooleanField(default=False)),
|
||||||
|
('attached_widget_label', models.CharField(blank=True, max_length=120)),
|
||||||
|
('attached_pages', models.CharField(blank=True, max_length=200)),
|
||||||
|
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='proof_card', to='core.job')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-is_featured', '-updated_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ReviewRequest',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
('status', models.CharField(choices=[('sent', 'Sent'), ('viewed', 'Viewed'), ('responded', 'Responded')], default='sent', max_length=20)),
|
||||||
|
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('manual', 'Manual share')], default='email', max_length=16)),
|
||||||
|
('sent_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('last_opened_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('reviewed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('delivery_note', models.CharField(blank=True, max_length=200)),
|
||||||
|
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review_request', to='core.job')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-sent_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Feedback',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('experience', models.CharField(choices=[('great', 'Great'), ('good', 'Good'), ('okay', 'Okay'), ('bad', 'Bad')], max_length=12)),
|
||||||
|
('rating', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||||
|
('testimonial', models.TextField(blank=True)),
|
||||||
|
('follow_up_required', models.BooleanField(default=False)),
|
||||||
|
('is_public_approved', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('review_request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='core.reviewrequest')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
98
core/migrations/0002_seed_trustforge_demo.py
Normal file
98
core/migrations/0002_seed_trustforge_demo.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def seed_trustforge_demo(apps, schema_editor):
|
||||||
|
Business = apps.get_model('core', 'Business')
|
||||||
|
Customer = apps.get_model('core', 'Customer')
|
||||||
|
Job = apps.get_model('core', 'Job')
|
||||||
|
ProofCard = apps.get_model('core', 'ProofCard')
|
||||||
|
ReviewRequest = apps.get_model('core', 'ReviewRequest')
|
||||||
|
Feedback = apps.get_model('core', 'Feedback')
|
||||||
|
|
||||||
|
business, _ = Business.objects.get_or_create(
|
||||||
|
slug='summit-home-services',
|
||||||
|
defaults={
|
||||||
|
'name': 'Summit Home Services',
|
||||||
|
'industry': 'Roofing & Exterior',
|
||||||
|
'primary_city': 'Austin',
|
||||||
|
'primary_state': 'TX',
|
||||||
|
'google_review_url': 'https://example.com/google-review',
|
||||||
|
'is_active': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
customer, _ = Customer.objects.get_or_create(
|
||||||
|
business=business,
|
||||||
|
full_name='Avery Johnson',
|
||||||
|
defaults={
|
||||||
|
'email': 'avery@example.com',
|
||||||
|
'phone': '(512) 555-0148',
|
||||||
|
'city': 'Austin',
|
||||||
|
'state': 'TX',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
job, _ = Job.objects.get_or_create(
|
||||||
|
business=business,
|
||||||
|
customer=customer,
|
||||||
|
service_type='Roof replacement',
|
||||||
|
defaults={
|
||||||
|
'description': 'Replaced weather-damaged shingles, sealed flashing, and completed a same-day cleanup.',
|
||||||
|
'technician_name': 'Luis Ramirez',
|
||||||
|
'city': 'Austin',
|
||||||
|
'state': 'TX',
|
||||||
|
'completed_at': timezone.localdate(),
|
||||||
|
'project_value': '14250.00',
|
||||||
|
'status': 'proof_ready',
|
||||||
|
'is_verified': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
proof_card, _ = ProofCard.objects.get_or_create(
|
||||||
|
job=job,
|
||||||
|
defaults={
|
||||||
|
'customer_display_name': 'Verified homeowner',
|
||||||
|
'is_anonymized': True,
|
||||||
|
'testimonial_quote': 'The crew communicated clearly, protected the landscaping, and finished the roof beautifully in one day.',
|
||||||
|
'rating': 5,
|
||||||
|
'status': 'published',
|
||||||
|
'is_featured': True,
|
||||||
|
'attached_widget_label': 'Homepage proof gallery',
|
||||||
|
'attached_pages': 'Homepage, Roofing service page',
|
||||||
|
'published_at': timezone.now(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
review_request, _ = ReviewRequest.objects.get_or_create(
|
||||||
|
job=job,
|
||||||
|
defaults={
|
||||||
|
'status': 'responded',
|
||||||
|
'channel': 'email',
|
||||||
|
'sent_at': timezone.now(),
|
||||||
|
'reviewed_at': timezone.now(),
|
||||||
|
'delivery_note': 'Seeded demo review request',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Feedback.objects.get_or_create(
|
||||||
|
review_request=review_request,
|
||||||
|
defaults={
|
||||||
|
'experience': 'great',
|
||||||
|
'rating': 5,
|
||||||
|
'testimonial': proof_card.testimonial_quote,
|
||||||
|
'follow_up_required': False,
|
||||||
|
'is_public_approved': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_trustforge_demo(apps, schema_editor):
|
||||||
|
Business = apps.get_model('core', 'Business')
|
||||||
|
Business.objects.filter(slug='summit-home-services').delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_trustforge_demo, remove_trustforge_demo),
|
||||||
|
]
|
||||||
31
core/migrations/0003_businessmembership.py
Normal file
31
core/migrations/0003_businessmembership.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-04-11 01:43
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0002_seed_trustforge_demo'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BusinessMembership',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('manager', 'Manager'), ('technician', 'Technician')], default='owner', max_length=24)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='core.business')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_memberships', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['business__name', 'user__email'],
|
||||||
|
'unique_together': {('business', 'user')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
222
core/models.py
222
core/models.py
@ -1,3 +1,221 @@
|
|||||||
from django.db import models
|
from __future__ import annotations
|
||||||
|
|
||||||
# Create your models here.
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Business(models.Model):
|
||||||
|
name = models.CharField(max_length=160)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
industry = models.CharField(max_length=120, default='Home Services')
|
||||||
|
primary_city = models.CharField(max_length=120)
|
||||||
|
primary_state = models.CharField(max_length=2)
|
||||||
|
google_review_url = models.URLField(blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
verbose_name_plural = 'businesses'
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def initials(self) -> str:
|
||||||
|
words = [chunk for chunk in self.name.split() if chunk]
|
||||||
|
if not words:
|
||||||
|
return 'TF'
|
||||||
|
return ''.join(word[0] for word in words[:2]).upper()
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessMembership(models.Model):
|
||||||
|
class Role(models.TextChoices):
|
||||||
|
OWNER = 'owner', 'Owner'
|
||||||
|
ADMIN = 'admin', 'Admin'
|
||||||
|
MANAGER = 'manager', 'Manager'
|
||||||
|
TECHNICIAN = 'technician', 'Technician'
|
||||||
|
|
||||||
|
business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='memberships')
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='business_memberships')
|
||||||
|
role = models.CharField(max_length=24, choices=Role.choices, default=Role.OWNER)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['business__name', 'user__email']
|
||||||
|
unique_together = ('business', 'user')
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
identity = self.user.get_full_name() or self.user.email or self.user.username
|
||||||
|
return f'{identity} · {self.business.name} ({self.get_role_display()})'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_manage_workspace(self) -> bool:
|
||||||
|
return self.role in {self.Role.OWNER, self.Role.ADMIN}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def can_manage_proof(self) -> bool:
|
||||||
|
return self.role in {self.Role.OWNER, self.Role.ADMIN, self.Role.MANAGER}
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(models.Model):
|
||||||
|
business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='customers')
|
||||||
|
full_name = models.CharField(max_length=160)
|
||||||
|
email = models.EmailField(blank=True)
|
||||||
|
phone = models.CharField(max_length=40, blank=True)
|
||||||
|
city = models.CharField(max_length=120)
|
||||||
|
state = models.CharField(max_length=2)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['full_name']
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f'{self.full_name} · {self.city}, {self.state}'
|
||||||
|
|
||||||
|
|
||||||
|
class Job(models.Model):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
COMPLETED = 'completed', 'Completed'
|
||||||
|
REVIEW_REQUESTED = 'review_requested', 'Review requested'
|
||||||
|
PROOF_READY = 'proof_ready', 'Proof ready'
|
||||||
|
|
||||||
|
business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='jobs')
|
||||||
|
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='jobs')
|
||||||
|
service_type = models.CharField(max_length=120)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
technician_name = models.CharField(max_length=120, blank=True)
|
||||||
|
city = models.CharField(max_length=120)
|
||||||
|
state = models.CharField(max_length=2)
|
||||||
|
completed_at = models.DateField(default=timezone.localdate)
|
||||||
|
project_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||||
|
status = models.CharField(max_length=32, choices=Status.choices, default=Status.COMPLETED)
|
||||||
|
is_verified = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-completed_at', '-created_at']
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f'{self.service_type} for {self.customer.full_name}'
|
||||||
|
|
||||||
|
def _get_media_by_type(self, media_type: str):
|
||||||
|
prefetched_media = getattr(self, '_prefetched_objects_cache', {}).get('media')
|
||||||
|
if prefetched_media is not None:
|
||||||
|
return next((media for media in prefetched_media if media.media_type == media_type), None)
|
||||||
|
return self.media.filter(media_type=media_type).first()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def before_media(self):
|
||||||
|
return self._get_media_by_type(JobMedia.MediaType.BEFORE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def after_media(self):
|
||||||
|
return self._get_media_by_type(JobMedia.MediaType.AFTER)
|
||||||
|
|
||||||
|
|
||||||
|
def job_media_upload_path(instance: 'JobMedia', filename: str) -> str:
|
||||||
|
return f'jobs/job_{instance.job_id}/{instance.media_type}_{filename}'
|
||||||
|
|
||||||
|
|
||||||
|
class JobMedia(models.Model):
|
||||||
|
class MediaType(models.TextChoices):
|
||||||
|
BEFORE = 'before', 'Before'
|
||||||
|
AFTER = 'after', 'After'
|
||||||
|
|
||||||
|
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='media')
|
||||||
|
media_type = models.CharField(max_length=16, choices=MediaType.choices)
|
||||||
|
file = models.FileField(upload_to=job_media_upload_path, blank=True)
|
||||||
|
caption = models.CharField(max_length=140, blank=True)
|
||||||
|
display_order = models.PositiveSmallIntegerField(default=0)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['display_order', 'id']
|
||||||
|
verbose_name_plural = 'job media'
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f'{self.job} · {self.get_media_type_display()}'
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewRequest(models.Model):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
SENT = 'sent', 'Sent'
|
||||||
|
VIEWED = 'viewed', 'Viewed'
|
||||||
|
RESPONDED = 'responded', 'Responded'
|
||||||
|
|
||||||
|
class Channel(models.TextChoices):
|
||||||
|
EMAIL = 'email', 'Email'
|
||||||
|
SMS = 'sms', 'SMS'
|
||||||
|
MANUAL = 'manual', 'Manual share'
|
||||||
|
|
||||||
|
job = models.OneToOneField(Job, on_delete=models.CASCADE, related_name='review_request')
|
||||||
|
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
|
status = models.CharField(max_length=20, choices=Status.choices, default=Status.SENT)
|
||||||
|
channel = models.CharField(max_length=16, choices=Channel.choices, default=Channel.EMAIL)
|
||||||
|
sent_at = models.DateTimeField(default=timezone.now)
|
||||||
|
last_opened_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
delivery_note = models.CharField(max_length=200, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-sent_at']
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f'Review request for {self.job}'
|
||||||
|
|
||||||
|
|
||||||
|
class Feedback(models.Model):
|
||||||
|
class Experience(models.TextChoices):
|
||||||
|
GREAT = 'great', 'Great'
|
||||||
|
GOOD = 'good', 'Good'
|
||||||
|
OKAY = 'okay', 'Okay'
|
||||||
|
BAD = 'bad', 'Bad'
|
||||||
|
|
||||||
|
review_request = models.OneToOneField(ReviewRequest, on_delete=models.CASCADE, related_name='feedback')
|
||||||
|
experience = models.CharField(max_length=12, choices=Experience.choices)
|
||||||
|
rating = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||||
|
testimonial = models.TextField(blank=True)
|
||||||
|
follow_up_required = models.BooleanField(default=False)
|
||||||
|
is_public_approved = models.BooleanField(default=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f'{self.get_experience_display()} feedback for {self.review_request.job}'
|
||||||
|
|
||||||
|
|
||||||
|
class ProofCard(models.Model):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
DRAFT = 'draft', 'Draft'
|
||||||
|
PUBLISHED = 'published', 'Published'
|
||||||
|
HIDDEN = 'hidden', 'Hidden'
|
||||||
|
|
||||||
|
job = models.OneToOneField(Job, on_delete=models.CASCADE, related_name='proof_card')
|
||||||
|
customer_display_name = models.CharField(max_length=160)
|
||||||
|
is_anonymized = models.BooleanField(default=False)
|
||||||
|
testimonial_quote = models.TextField(blank=True)
|
||||||
|
rating = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||||
|
status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)
|
||||||
|
is_featured = models.BooleanField(default=False)
|
||||||
|
attached_widget_label = models.CharField(max_length=120, blank=True)
|
||||||
|
attached_pages = models.CharField(max_length=200, blank=True)
|
||||||
|
published_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-is_featured', '-updated_at']
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f'Proof card · {self.job.service_type} · {self.customer_display_name}'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def verified_label(self) -> str:
|
||||||
|
return 'Verified job' if self.job.is_verified else 'Pending verification'
|
||||||
|
|||||||
@ -1,25 +1,133 @@
|
|||||||
<!DOCTYPE html>
|
{% load static %}
|
||||||
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="utf-8">
|
||||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
{% if project_description %}
|
<title>{% block title %}TrustForge{% endblock %}</title>
|
||||||
<meta name="description" content="{{ project_description }}">
|
<meta name="description" content="{% block meta_description %}{{ project_description|default:'TrustForge turns completed jobs into visual proof, testimonials, and conversion assets for service businesses.' }}{% endblock %}">
|
||||||
<meta property="og:description" content="{{ project_description }}">
|
<meta name="author" content="TrustForge">
|
||||||
<meta property="twitter:description" content="{{ project_description }}">
|
<meta name="keywords" content="proof cards, service business reviews, trust marketing, contractor testimonials, local service SaaS">
|
||||||
{% endif %}
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
{% if project_image_url %}
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<meta property="og:image" content="{{ project_image_url }}">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||||
{% endif %}
|
|
||||||
{% load static %}
|
|
||||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
<body class="trustforge-body">
|
||||||
|
<div class="tf-background-glow tf-background-glow-1"></div>
|
||||||
|
<div class="tf-background-glow tf-background-glow-2"></div>
|
||||||
|
|
||||||
<body>
|
<header class="tf-site-header sticky-top">
|
||||||
|
<nav class="navbar navbar-expand-lg tf-navbar">
|
||||||
|
<div class="container py-2">
|
||||||
|
<a class="navbar-brand tf-brand" href="{% url 'home' %}">
|
||||||
|
<span class="tf-brand-mark">TF</span>
|
||||||
|
<span>TrustForge</span>
|
||||||
|
</a>
|
||||||
|
<button class="navbar-toggler tf-nav-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#tfNav" aria-controls="tfNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="tfNav">
|
||||||
|
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
{% if current_membership %}
|
||||||
|
<li class="nav-item d-none d-lg-flex">
|
||||||
|
<div class="tf-workspace-chip">
|
||||||
|
<span class="tf-workspace-chip-mark">{{ current_membership.business.initials }}</span>
|
||||||
|
<span>
|
||||||
|
<strong>{{ current_membership.business.name }}</strong>
|
||||||
|
<small>{{ current_membership.get_role_display }}</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'dashboard' %}">Dashboard</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'jobs_list' %}">Jobs</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'proof_cards_list' %}">Proof Cards</a></li>
|
||||||
|
<li class="nav-item ms-lg-2"><a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Complete a job</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'business_onboarding' %}">Create workspace</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="nav-item dropdown ms-lg-2">
|
||||||
|
<button class="btn tf-user-menu dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<span class="tf-user-menu-label">{{ request.user.first_name|default:request.user.email|truncatechars:18 }}</span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end tf-user-dropdown">
|
||||||
|
{% if current_membership %}
|
||||||
|
<li class="dropdown-item-text tf-dropdown-label">
|
||||||
|
<strong>{{ current_membership.business.name }}</strong>
|
||||||
|
<span>{{ current_membership.get_role_display }}</span>
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'dashboard' %}">Workspace dashboard</a></li>
|
||||||
|
{% if current_membership.can_manage_workspace %}
|
||||||
|
<li><a class="dropdown-item" href="{% url 'workspace_settings' %}">Workspace settings</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<li><a class="dropdown-item" href="{% url 'business_onboarding' %}">Create workspace</a></li>
|
||||||
|
{% endif %}
|
||||||
|
<li><a class="dropdown-item" href="{% url 'profile_settings' %}">Profile & settings</a></li>
|
||||||
|
<li><a class="dropdown-item" href="/admin/">Admin</a></li>
|
||||||
|
{% if user_memberships|length > 1 %}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li class="dropdown-item-text tf-dropdown-section">Switch workspace</li>
|
||||||
|
{% for membership in user_memberships %}
|
||||||
|
<li>
|
||||||
|
<form method="post" action="{% url 'switch_workspace' membership.business_id %}" class="px-2 pb-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.path }}">
|
||||||
|
<button type="submit" class="dropdown-item tf-workspace-switch{% if current_membership and membership.business_id == current_membership.business_id %} active{% endif %}">
|
||||||
|
{{ membership.business.name }}
|
||||||
|
<small>{{ membership.get_role_display }}</small>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<form method="post" action="{% url 'logout' %}" class="px-2 pb-2">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="dropdown-item tf-logout-link">Log out</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Login</a></li>
|
||||||
|
<li class="nav-item ms-lg-2"><a class="btn tf-btn tf-btn-primary" href="{% url 'signup' %}">Get Started</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="container pt-4">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert tf-alert alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<main>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</body>
|
</main>
|
||||||
|
|
||||||
|
<footer class="tf-footer py-5">
|
||||||
|
<div class="container d-flex flex-column flex-lg-row gap-3 justify-content-between align-items-lg-center">
|
||||||
|
<div>
|
||||||
|
<div class="tf-footer-brand">TrustForge</div>
|
||||||
|
<p class="mb-0 text-secondary-emphasis">Proof > reviews for home service businesses.</p>
|
||||||
|
</div>
|
||||||
|
<div class="small text-secondary-emphasis">Built for contractors, HVAC, roofing, plumbing, electrical, landscaping, and local service teams.</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
76
core/templates/core/business_onboarding.html
Normal file
76
core/templates/core/business_onboarding.html
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Create Your Workspace | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Create your TrustForge business workspace, set your service area, and start the protected proof pipeline.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4 align-items-start">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel sticky-lg-top tf-sticky-panel">
|
||||||
|
<div class="tf-eyebrow">Business onboarding</div>
|
||||||
|
<h1 class="tf-page-title">Create your first TrustForge workspace</h1>
|
||||||
|
<p class="tf-page-subtitle">This workspace becomes the protected home for your completed jobs, review requests, proof cards, and team permissions.</p>
|
||||||
|
<div class="d-grid gap-3 mt-4">
|
||||||
|
<div class="tf-check-row"><span>01</span> Create your business workspace</div>
|
||||||
|
<div class="tf-check-row"><span>02</span> Become the owner automatically</div>
|
||||||
|
<div class="tf-check-row"><span>03</span> Invite admins, managers, and technicians</div>
|
||||||
|
<div class="tf-check-row"><span>04</span> Keep every job and proof asset scoped securely</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="tf-panel">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Workspace details</div>
|
||||||
|
<h2 class="h3 mb-2">Set up the business your team will operate inside</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-0">You can update branding details later from workspace settings. The trust pipeline and team access will use this business as the default scope.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tf-inline-stat">
|
||||||
|
<span>Protected SaaS</span>
|
||||||
|
<strong>Multi-tenant ready</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="row g-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Business name</label>
|
||||||
|
{{ form.name }}
|
||||||
|
{% if form.name.errors %}<div class="text-danger small mt-1">{{ form.name.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Industry</label>
|
||||||
|
{{ form.industry }}
|
||||||
|
{% if form.industry.errors %}<div class="text-danger small mt-1">{{ form.industry.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Primary city</label>
|
||||||
|
{{ form.primary_city }}
|
||||||
|
{% if form.primary_city.errors %}<div class="text-danger small mt-1">{{ form.primary_city.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">State</label>
|
||||||
|
{{ form.primary_state }}
|
||||||
|
{% if form.primary_state.errors %}<div class="text-danger small mt-1">{{ form.primary_state.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Google review URL <span class="text-secondary-emphasis">(optional)</span></label>
|
||||||
|
{{ form.google_review_url }}
|
||||||
|
{% if form.google_review_url.errors %}<div class="text-danger small mt-1">{{ form.google_review_url.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="col-12"><div class="text-danger small">{{ form.non_field_errors|join:', ' }}</div></div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="col-12 pt-2 d-flex flex-wrap gap-3 align-items-center">
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary">Create workspace</button>
|
||||||
|
<span class="small text-secondary-emphasis">You will be assigned as the owner and land inside your dashboard immediately.</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
84
core/templates/core/dashboard.html
Normal file
84
core/templates/core/dashboard.html
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}TrustForge dashboard for completed jobs, review requests, proof cards, and conversion momentum.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Dashboard</div>
|
||||||
|
<h1 class="tf-page-title">{{ current_membership.business.name }} proof momentum</h1>
|
||||||
|
<p class="tf-page-subtitle">This dashboard is scoped to your current workspace only. Track completed jobs, response volume, published proof, and the conversion signal your team is creating this week.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' current_membership.business.slug %}" target="_blank" rel="noopener">View public gallery</a>
|
||||||
|
{% if current_membership.can_manage_workspace %}<a class="btn tf-btn tf-btn-secondary" href="{% url 'workspace_settings' %}">Workspace settings</a>{% endif %}
|
||||||
|
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Log a new completed job</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mb-4">
|
||||||
|
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ stats.completed_jobs|default:0 }}</span><p>Jobs completed</p></div></div>
|
||||||
|
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ stats.review_requests|default:0 }}</span><p>Review requests</p></div></div>
|
||||||
|
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ stats.proof_cards|default:0 }}</span><p>Proof cards created</p></div></div>
|
||||||
|
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ conversion_rate }}%</span><p>Positive response rate</p></div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h4 mb-0">Recent jobs</h2>
|
||||||
|
<a href="{% url 'jobs_list' %}" class="tf-inline-link">View all</a>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
{% for job in recent_jobs %}
|
||||||
|
<a href="{% url 'job_detail' job.id %}" class="tf-activity-link">
|
||||||
|
<div class="tf-activity-row tf-activity-row-soft">
|
||||||
|
<div>
|
||||||
|
<strong>{{ job.service_type }}</strong>
|
||||||
|
<div class="small text-secondary-emphasis">{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% empty %}
|
||||||
|
<div class="tf-empty-state text-start">
|
||||||
|
<h3 class="h5 mb-2">No jobs yet</h3>
|
||||||
|
<p class="text-secondary-emphasis mb-0">Use the field intake form to start the first proof workflow for {{ current_membership.business.name }}.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h4 mb-0">Recent proof cards</h2>
|
||||||
|
<a href="{% url 'proof_cards_list' %}" class="tf-inline-link">Open gallery</a>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
{% for proof in recent_proofs %}
|
||||||
|
<a href="{% url 'proof_card_detail' proof.id %}" class="tf-proof-mini">
|
||||||
|
<div>
|
||||||
|
<div class="tf-card-tag mb-2">{{ proof.job.service_type }}</div>
|
||||||
|
<strong>{{ proof.job.city }}, {{ proof.job.state }}</strong>
|
||||||
|
<div class="small text-secondary-emphasis">{{ proof.customer_display_name }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
|
||||||
|
</a>
|
||||||
|
{% empty %}
|
||||||
|
<div class="tf-empty-state text-start">
|
||||||
|
<h3 class="h5 mb-2">No proof cards yet</h3>
|
||||||
|
<p class="text-secondary-emphasis mb-0">Every completed job inside this workspace instantly creates a draft proof card.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
14
core/templates/core/includes/proof_media_grid.html
Normal file
14
core/templates/core/includes/proof_media_grid.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<div class="tf-proof-media-grid{% if grid_class %} {{ grid_class }}{% endif %}">
|
||||||
|
<div class="tf-photo-slot{% if job.before_media and job.before_media.file %} tf-photo-slot-has-media{% endif %}">
|
||||||
|
{% if job.before_media and job.before_media.file %}
|
||||||
|
<img src="{{ job.before_media.file.url }}" alt="Before photo for {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}" class="tf-photo-image" width="960" height="720">
|
||||||
|
{% endif %}
|
||||||
|
<span class="tf-photo-label">Before</span>
|
||||||
|
</div>
|
||||||
|
<div class="tf-photo-slot tf-photo-slot-after{% if job.after_media and job.after_media.file %} tf-photo-slot-has-media{% endif %}">
|
||||||
|
{% if job.after_media and job.after_media.file %}
|
||||||
|
<img src="{{ job.after_media.file.url }}" alt="After photo for {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}" class="tf-photo-image" width="960" height="720">
|
||||||
|
{% endif %}
|
||||||
|
<span class="tf-photo-label">After</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -1,145 +1,195 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ project_name }}{% endblock %}
|
{% block title %}TrustForge | Turn completed jobs into proof that wins the next customer{% endblock %}
|
||||||
|
{% block meta_description %}TrustForge helps service businesses transform completed jobs into proof cards, review requests, and conversion assets that win more booked work.{% endblock %}
|
||||||
{% block head %}
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg-color-start: #6a11cb;
|
|
||||||
--bg-color-end: #2575fc;
|
|
||||||
--text-color: #ffffff;
|
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
|
||||||
animation: bg-pan 20s linear infinite;
|
|
||||||
z-index: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bg-pan {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-position: 100% 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card-bg-color);
|
|
||||||
border: 1px solid var(--card-border-color);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
-webkit-backdrop-filter: blur(20px);
|
|
||||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0 0 1.2rem;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0.92;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
margin: 1.5rem auto;
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtime code {
|
|
||||||
background: rgba(0, 0, 0, 0.25);
|
|
||||||
padding: 0.15rem 0.45rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sr-only {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
padding: 0;
|
|
||||||
margin: -1px;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 1rem;
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<main>
|
<section class="tf-hero-section py-5 py-lg-6">
|
||||||
<div class="card">
|
<div class="container py-lg-5">
|
||||||
<h1>Analyzing your requirements and generating your app…</h1>
|
<div class="row align-items-center g-5">
|
||||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
<div class="col-lg-6">
|
||||||
<span class="sr-only">Loading…</span>
|
<div class="tf-eyebrow mb-3">Trust engine for service businesses</div>
|
||||||
|
<h1 class="tf-display mb-4">Every completed job becomes proof that closes the next one.</h1>
|
||||||
|
<p class="tf-lead mb-4">TrustForge gives contractors, roofers, HVAC teams, plumbers, electricians, junk removal crews, and landscapers a fast field workflow: finish the job, send the review request, generate a premium proof card, and publish conversion-ready assets.</p>
|
||||||
|
<div class="d-flex flex-wrap gap-3 mb-4">
|
||||||
|
<a class="btn tf-btn tf-btn-primary btn-lg" href="{% url 'job_create' %}">Start a completed job</a>
|
||||||
|
<a class="btn tf-btn tf-btn-secondary btn-lg" href="{% url 'dashboard' %}">View dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
<div class="row g-3 tf-stat-row">
|
||||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
<div class="col-6 col-md-3">
|
||||||
<p class="runtime">
|
<div class="tf-stat-chip">
|
||||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
<span>{{ stats.completed_jobs|default:0 }}</span>
|
||||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
Jobs logged
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
<footer>
|
<div class="col-6 col-md-3">
|
||||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
<div class="tf-stat-chip">
|
||||||
</footer>
|
<span>{{ stats.review_requests|default:0 }}</span>
|
||||||
|
Requests sent
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="tf-stat-chip">
|
||||||
|
<span>{{ stats.proof_cards|default:0 }}</span>
|
||||||
|
Proof cards
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-md-3">
|
||||||
|
<div class="tf-stat-chip">
|
||||||
|
<span>{{ business_count }}</span>
|
||||||
|
Active businesses
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="tf-device-card">
|
||||||
|
<div class="tf-device-header">
|
||||||
|
<span></span><span></span><span></span>
|
||||||
|
</div>
|
||||||
|
<div class="tf-device-body">
|
||||||
|
<div class="tf-mini-step active">1. Job completed</div>
|
||||||
|
<div class="tf-mini-step active">2. Review requested</div>
|
||||||
|
<div class="tf-mini-step active">3. Proof created</div>
|
||||||
|
<div class="tf-proof-preview mt-4">
|
||||||
|
{% if featured_proofs %}
|
||||||
|
{% with proof=featured_proofs.0 %}
|
||||||
|
<div class="mb-3">
|
||||||
|
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<div class="tf-card-tag">{{ proof.job.service_type }}</div>
|
||||||
|
<h2 class="tf-card-title h4 mt-2 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
|
||||||
|
<div class="text-secondary-emphasis small">Verified completion · {{ proof.job.completed_at|date:"M j, Y" }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="tf-rating-pill">{% if proof.rating %}★ {{ proof.rating }}.0{% else %}Verified{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:120 }}”{% else %}Premium proof cards turn real field work into a conversion asset that belongs on your homepage and service pages.{% endif %}</p>
|
||||||
|
<div class="d-flex justify-content-between small text-secondary-emphasis">
|
||||||
|
<span>{{ proof.customer_display_name }}</span>
|
||||||
|
<span>{{ proof.verified_label }}</span>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% else %}
|
||||||
|
<div class="tf-empty-proof">
|
||||||
|
<div class="tf-proof-media-grid mb-3">
|
||||||
|
<div class="tf-photo-slot">Before</div>
|
||||||
|
<div class="tf-photo-slot tf-photo-slot-after">After</div>
|
||||||
|
</div>
|
||||||
|
<h2 class="h4 mb-2">Your first proof card appears here</h2>
|
||||||
|
<p class="mb-0 text-secondary-emphasis">Log a completed job to instantly generate the draft card, review request, and proof pipeline.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<div class="tf-panel-icon">⚡</div>
|
||||||
|
<h2 class="h4 mb-3">Fast field workflow</h2>
|
||||||
|
<p class="mb-0 text-secondary-emphasis">Capture a completed job, upload before/after photos, and send a review request from a mobile-friendly form designed for technicians on-site.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<div class="tf-panel-icon">🛡️</div>
|
||||||
|
<h2 class="h4 mb-3">Proof-first cards</h2>
|
||||||
|
<p class="mb-0 text-secondary-emphasis">Every job creates a premium proof card with service, location, photos, verification status, testimonial, and publishing controls.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<div class="tf-panel-icon">📈</div>
|
||||||
|
<h2 class="h4 mb-3">Conversion-ready assets</h2>
|
||||||
|
<p class="mb-0 text-secondary-emphasis">Feature standout work on your landing page, service pages, and proof gallery to give new customers visible confidence.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pb-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-end flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Featured proof</div>
|
||||||
|
<h2 class="tf-section-title">Recent conversion assets</h2>
|
||||||
|
</div>
|
||||||
|
{% if request.user.is_authenticated and current_membership %}
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_cards_list' %}">Open your proof cards</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'signup' %}">Get started free</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for proof in featured_proofs %}
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' proof.job.business.slug proof.id %}">
|
||||||
|
<article class="tf-proof-card h-100">
|
||||||
|
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
|
||||||
|
<div class="tf-proof-card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
|
||||||
|
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
|
||||||
|
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h3>
|
||||||
|
<p class="small text-secondary-emphasis mb-3">Completed {{ proof.job.completed_at|date:"M j, Y" }}</p>
|
||||||
|
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:115 }}”{% else %}A ready-to-publish proof card waiting for review feedback and promotion.{% endif %}</p>
|
||||||
|
<div class="d-flex justify-content-between small text-secondary-emphasis">
|
||||||
|
<span>{{ proof.customer_display_name }}</span>
|
||||||
|
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}No rating yet{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="tf-empty-state text-center">
|
||||||
|
<h3 class="h4 mb-2">No proof cards featured yet</h3>
|
||||||
|
<p class="text-secondary-emphasis mb-4">Create your first completed job to populate the TrustForge proof gallery.</p>
|
||||||
|
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Create the first job</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="pb-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="tf-panel tf-panel-dark">
|
||||||
|
<div class="row g-4 align-items-center">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="tf-eyebrow tf-eyebrow-light">Pipeline</div>
|
||||||
|
<h2 class="tf-section-title text-white">Job Completed → Review Requested → Proof Card Created → Displayed → Converts Next Customer</h2>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
{% for job in recent_jobs %}
|
||||||
|
<div class="tf-activity-row">
|
||||||
|
<div>
|
||||||
|
<strong>{{ job.service_type }}</strong>
|
||||||
|
<div class="small text-secondary-emphasis">{{ job.business.name }} · {{ job.city }}, {{ job.state }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="mb-0 text-secondary-emphasis">No jobs yet. The first intake will immediately show up here.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
78
core/templates/core/job_detail.html
Normal file
78
core/templates/core/job_detail.html
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ job.service_type }} | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}TrustForge job detail for {{ job.service_type }} in {{ job.city }}, {{ job.state }}.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Job detail</div>
|
||||||
|
<h1 class="tf-page-title">{{ job.service_type }}</h1>
|
||||||
|
<p class="tf-page-subtitle">{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }} · Completed {{ job.completed_at|date:"F j, Y" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'jobs_list' %}">All jobs</a>
|
||||||
|
<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_detail' job.proof_card.id %}">Open proof card</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h4 mb-0">Job summary</h2>
|
||||||
|
<span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-4">
|
||||||
|
<div class="col-md-6"><div class="tf-detail-box"><span>Business</span><strong>{{ job.business.name }}</strong></div></div>
|
||||||
|
<div class="col-md-6"><div class="tf-detail-box"><span>Technician</span><strong>{{ job.technician_name|default:'Not added yet' }}</strong></div></div>
|
||||||
|
<div class="col-md-6"><div class="tf-detail-box"><span>Project value</span><strong>{% if job.project_value %}${{ job.project_value }}{% else %}Optional{% endif %}</strong></div></div>
|
||||||
|
<div class="col-md-6"><div class="tf-detail-box"><span>Verified</span><strong>{% if job.is_verified %}Yes{% else %}Pending{% endif %}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="h6 text-uppercase text-secondary-emphasis mb-2">Description</h3>
|
||||||
|
<p class="mb-0">{{ job.description|default:'No extra description was added for this completed job.' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="h6 text-uppercase text-secondary-emphasis mb-2">Job photos</h3>
|
||||||
|
{% include "core/includes/proof_media_grid.html" with job=job %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel mb-4">
|
||||||
|
<h2 class="h4 mb-3">Review request</h2>
|
||||||
|
{% if job.review_request %}
|
||||||
|
<div class="tf-detail-box mb-3"><span>Status</span><strong>{{ job.review_request.get_status_display }}</strong></div>
|
||||||
|
<div class="tf-detail-box mb-3"><span>Channel</span><strong>{{ job.review_request.get_channel_display }}</strong></div>
|
||||||
|
<div class="tf-detail-box mb-3"><span>Delivery note</span><strong>{{ job.review_request.delivery_note|default:'Ready to share' }}</strong></div>
|
||||||
|
<a href="{% url 'review_request' job.review_request.token %}" class="btn tf-btn tf-btn-secondary w-100">Open customer review page</a>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-secondary-emphasis">No review request has been sent for this job yet.</p>
|
||||||
|
<form method="post" class="d-grid gap-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="send_review_request">
|
||||||
|
<select name="channel" class="form-select">
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="sms">SMS</option>
|
||||||
|
<option value="manual">Manual share</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary">Send review request</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tf-panel">
|
||||||
|
<h2 class="h4 mb-3">Proof card</h2>
|
||||||
|
<div class="tf-detail-box mb-3"><span>Status</span><strong>{{ job.proof_card.get_status_display }}</strong></div>
|
||||||
|
<div class="tf-detail-box mb-3"><span>Display name</span><strong>{{ job.proof_card.customer_display_name }}</strong></div>
|
||||||
|
<div class="tf-detail-box mb-4"><span>Widget target</span><strong>{{ job.proof_card.attached_widget_label|default:'Not set' }}</strong></div>
|
||||||
|
<a href="{% url 'proof_card_detail' job.proof_card.id %}" class="btn tf-btn tf-btn-primary w-100">Manage proof card</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
114
core/templates/core/job_form.html
Normal file
114
core/templates/core/job_form.html
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Complete a Job | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Log a completed service job in TrustForge, upload before and after photos, and trigger the proof workflow.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4 align-items-start">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel sticky-lg-top tf-sticky-panel">
|
||||||
|
<div class="tf-eyebrow">Technician flow</div>
|
||||||
|
<h1 class="tf-page-title">Complete a job in under 30 seconds</h1>
|
||||||
|
<p class="tf-page-subtitle">You are logging this inside <strong>{{ current_membership.business.name }}</strong>. Add the customer, upload photos, and trigger a review request. TrustForge creates the proof card automatically.</p>
|
||||||
|
<div class="d-grid gap-3 mt-4">
|
||||||
|
<div class="tf-check-row"><span>01</span> Save the completed job</div>
|
||||||
|
<div class="tf-check-row"><span>02</span> Draft proof card is generated</div>
|
||||||
|
<div class="tf-check-row"><span>03</span> Review request is ready to share</div>
|
||||||
|
<div class="tf-check-row"><span>04</span> Positive feedback can auto-publish proof</div>
|
||||||
|
</div>
|
||||||
|
<div class="tf-detail-box mt-4"><span>Workspace scope</span><strong>{{ current_membership.business.primary_city }}, {{ current_membership.business.primary_state }} · {{ current_membership.get_role_display }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="tf-panel">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Job intake</div>
|
||||||
|
<h2 class="h3 mb-2">Capture the completed work</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-0">Every field below feeds the trust pipeline: completed job → review request → proof card.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tf-inline-stat">
|
||||||
|
<span>Business scope</span>
|
||||||
|
<strong>{{ current_membership.business.name }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" enctype="multipart/form-data" class="row g-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.business }}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Customer name</label>
|
||||||
|
{{ form.customer_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Service type</label>
|
||||||
|
{{ form.service_type }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Customer email</label>
|
||||||
|
{{ form.customer_email }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Customer phone</label>
|
||||||
|
{{ form.customer_phone }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label">City</label>
|
||||||
|
{{ form.customer_city }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label">State</label>
|
||||||
|
{{ form.customer_state }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Technician</label>
|
||||||
|
{{ form.technician_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Completion date</label>
|
||||||
|
{{ form.completion_date }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Project value</label>
|
||||||
|
{{ form.project_value }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Review channel</label>
|
||||||
|
{{ form.review_channel }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Job description</label>
|
||||||
|
{{ form.description }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Before photo</label>
|
||||||
|
{{ form.before_photo }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">After photo</label>
|
||||||
|
{{ form.after_photo }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check tf-check-field">
|
||||||
|
{{ form.anonymize_customer }}
|
||||||
|
<label class="form-check-label" for="{{ form.anonymize_customer.id_for_label }}">Anonymize customer on proof card</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check tf-check-field">
|
||||||
|
{{ form.send_review_request }}
|
||||||
|
<label class="form-check-label" for="{{ form.send_review_request.id_for_label }}">Send review request after save</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 pt-2 d-flex flex-wrap gap-3">
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary">Save completed job</button>
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'jobs_list' %}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
59
core/templates/core/jobs_list.html
Normal file
59
core/templates/core/jobs_list.html
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Jobs | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Browse completed jobs in TrustForge and open each proof workflow detail.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Jobs</div>
|
||||||
|
<h1 class="tf-page-title">{{ current_membership.business.name }} completed job pipeline</h1>
|
||||||
|
<p class="tf-page-subtitle">Every job below belongs only to this workspace and can lead to a proof card, published testimonial, and higher conversion confidence.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Add completed job</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tf-panel p-0 overflow-hidden">
|
||||||
|
{% if jobs %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table tf-table align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Service</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Completed</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for job in jobs %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>{{ job.customer.full_name }}</strong>
|
||||||
|
<div class="small text-secondary-emphasis">{{ job.technician_name|default:'Technician not added yet' }}</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ job.service_type }}</td>
|
||||||
|
<td>{{ job.city }}, {{ job.state }}</td>
|
||||||
|
<td>{{ job.completed_at|date:"M j, Y" }}</td>
|
||||||
|
<td><span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span></td>
|
||||||
|
<td class="text-end"><a href="{% url 'job_detail' job.id %}" class="tf-inline-link">Open</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="tf-empty-state text-center m-4">
|
||||||
|
<h2 class="h4 mb-2">No jobs completed yet</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-4">Start with one field intake and TrustForge will spin up the proof workflow automatically for this business.</p>
|
||||||
|
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Create first job</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
77
core/templates/core/profile_settings.html
Normal file
77
core/templates/core/profile_settings.html
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Profile & Settings | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Manage your TrustForge profile, workspace memberships, and account settings.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Profile settings</div>
|
||||||
|
<h1 class="tf-page-title">Your account identity</h1>
|
||||||
|
<p class="tf-page-subtitle">Update your login profile and review which TrustForge workspaces you belong to.</p>
|
||||||
|
</div>
|
||||||
|
{% if current_membership and current_membership.can_manage_workspace %}
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'workspace_settings' %}">Workspace settings</a>
|
||||||
|
{% elif not current_membership %}
|
||||||
|
<a class="btn tf-btn tf-btn-primary" href="{% url 'business_onboarding' %}">Create workspace</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<h2 class="h4 mb-3">Account details</h2>
|
||||||
|
<form method="post" class="row g-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">First name</label>
|
||||||
|
{{ form.first_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Last name</label>
|
||||||
|
{{ form.last_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
{{ form.email }}
|
||||||
|
<div class="small text-secondary-emphasis mt-2">This email is also your login and password reset destination.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 pt-2">
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary">Save profile</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<h2 class="h4 mb-3">Workspace access</h2>
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
{% for membership in memberships %}
|
||||||
|
<div class="tf-team-member{% if current_membership and membership.business_id == current_membership.business_id %} tf-team-member-active{% endif %}">
|
||||||
|
<div>
|
||||||
|
<strong>{{ membership.business.name }}</strong>
|
||||||
|
<div class="small text-secondary-emphasis">{{ membership.business.primary_city }}, {{ membership.business.primary_state }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="tf-status-pill tf-role-pill">{{ membership.get_role_display }}</span>
|
||||||
|
{% if current_membership and membership.business_id == current_membership.business_id %}
|
||||||
|
<div class="small text-secondary-emphasis mt-1">Current workspace</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="tf-empty-state text-start">
|
||||||
|
<h3 class="h5 mb-2">No workspace yet</h3>
|
||||||
|
<p class="text-secondary-emphasis mb-4">Create your first business workspace to turn TrustForge into a real tenant-scoped SaaS account.</p>
|
||||||
|
<a class="btn tf-btn tf-btn-primary" href="{% url 'business_onboarding' %}">Create workspace</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
84
core/templates/core/proof_card_detail.html
Normal file
84
core/templates/core/proof_card_detail.html
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Proof Card | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}View a TrustForge proof card and manage publishing controls for the active workspace.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Proof card</div>
|
||||||
|
<h1 class="tf-page-title">{{ proof_card.job.service_type }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}</h1>
|
||||||
|
<p class="tf-page-subtitle">{{ proof_card.customer_display_name }} · Scoped to {{ proof_card.job.business.name }} · Completed {{ proof_card.job.completed_at|date:"F j, Y" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_cards_list' %}">All proof cards</a>
|
||||||
|
{% if proof_card.status == 'published' %}<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_detail' proof_card.job.business.slug proof_card.id %}" target="_blank" rel="noopener">Open public proof</a>{% endif %}
|
||||||
|
{% if current_membership.can_manage_proof %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_edit' proof_card.id %}">Edit proof card</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<article class="tf-proof-card tf-proof-card-expanded h-100">
|
||||||
|
{% include "core/includes/proof_media_grid.html" with job=proof_card.job grid_class='tf-proof-media-grid-large' %}
|
||||||
|
<div class="tf-proof-card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
|
||||||
|
<span class="tf-card-tag">{{ proof_card.job.service_type }}</span>
|
||||||
|
<span class="tf-badge-verified">{{ proof_card.verified_label }}</span>
|
||||||
|
</div>
|
||||||
|
<blockquote class="tf-testimonial mb-4">{% if proof_card.testimonial_quote %}“{{ proof_card.testimonial_quote }}”{% else %}Add a testimonial or collect one through the review request page to strengthen this proof asset.{% endif %}</blockquote>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4"><div class="tf-detail-box"><span>Rating</span><strong>{% if proof_card.rating %}★ {{ proof_card.rating }} / 5{% else %}Pending{% endif %}</strong></div></div>
|
||||||
|
<div class="col-md-4"><div class="tf-detail-box"><span>Featured</span><strong>{% if proof_card.is_featured %}Yes{% else %}No{% endif %}</strong></div></div>
|
||||||
|
<div class="col-md-4"><div class="tf-detail-box"><span>Widget target</span><strong>{{ proof_card.attached_widget_label|default:'Not set' }}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel mb-4">
|
||||||
|
<h2 class="h4 mb-3">Quick actions</h2>
|
||||||
|
{% if current_membership.can_manage_proof %}
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="publish">
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Publish proof card</button>
|
||||||
|
</form>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="hide">
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-secondary w-100">Hide from public proof gallery</button>
|
||||||
|
</form>
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="toggle_featured">
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-secondary w-100">{% if proof_card.is_featured %}Remove from featured proof{% else %}Feature on landing experience{% endif %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="tf-empty-state text-start">
|
||||||
|
<h3 class="h5 mb-2">View-only access</h3>
|
||||||
|
<p class="text-secondary-emphasis mb-0">Your current role can view proof assets in this workspace but cannot change publishing controls.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="tf-panel">
|
||||||
|
<h2 class="h4 mb-3">Placement</h2>
|
||||||
|
<div class="tf-detail-box mb-3"><span>Attach to widgets</span><strong>{{ proof_card.attached_widget_label|default:'Homepage proof gallery' }}</strong></div>
|
||||||
|
<div class="tf-detail-box mb-3"><span>Attach to pages</span><strong>{{ proof_card.attached_pages|default:'Homepage' }}</strong></div>
|
||||||
|
<div class="tf-detail-box mb-3"><span>Public gallery</span><strong><a class="tf-inline-link" href="{% url 'public_proof_gallery' proof_card.job.business.slug %}" target="_blank" rel="noopener">/proof/{{ proof_card.job.business.slug }}/</a></strong></div>
|
||||||
|
{% if proof_card.status == 'published' %}
|
||||||
|
<div class="tf-detail-box mb-3"><span>Public proof page</span><strong><a class="tf-inline-link" href="{% url 'public_proof_detail' proof_card.job.business.slug proof_card.id %}" target="_blank" rel="noopener">/proof/{{ proof_card.job.business.slug }}/{{ proof_card.id }}/</a></strong></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if proof_card.job.review_request %}
|
||||||
|
<a class="btn tf-btn tf-btn-secondary w-100" href="{% url 'review_request' proof_card.job.review_request.token %}">Open review request page</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
65
core/templates/core/proof_card_form.html
Normal file
65
core/templates/core/proof_card_form.html
Normal file
@ -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 %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Edit proof card</div>
|
||||||
|
<h1 class="tf-page-title">Refine the conversion asset</h1>
|
||||||
|
<p class="tf-page-subtitle">Update the customer display name, testimonial, placement, and publish settings without touching the underlying job record.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_card_detail' proof_card.id %}">Back to proof card</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tf-panel">
|
||||||
|
<form method="post" class="row g-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Customer display name</label>
|
||||||
|
{{ form.customer_display_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Rating</label>
|
||||||
|
{{ form.rating }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Testimonial quote</label>
|
||||||
|
{{ form.testimonial_quote }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
{{ form.status }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Attach to widget</label>
|
||||||
|
{{ form.attached_widget_label }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Attach to pages</label>
|
||||||
|
{{ form.attached_pages }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check tf-check-field">
|
||||||
|
{{ form.is_anonymized }}
|
||||||
|
<label class="form-check-label" for="{{ form.is_anonymized.id_for_label }}">Anonymize customer</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check tf-check-field">
|
||||||
|
{{ form.is_featured }}
|
||||||
|
<label class="form-check-label" for="{{ form.is_featured.id_for_label }}">Feature this proof card</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex gap-3 pt-2">
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary">Save proof card</button>
|
||||||
|
<a href="{% url 'proof_card_detail' proof_card.id %}" class="btn tf-btn tf-btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
58
core/templates/core/proof_cards_list.html
Normal file
58
core/templates/core/proof_cards_list.html
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Proof Cards | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Browse TrustForge proof cards scoped to your current workspace.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Proof cards</div>
|
||||||
|
<h1 class="tf-page-title">{{ current_membership.business.name }} proof gallery</h1>
|
||||||
|
<p class="tf-page-subtitle">These conversion assets are tenant-scoped to the active workspace and ready for review, publishing, or featuring.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' current_membership.business.slug %}" target="_blank" rel="noopener">Open public gallery</a>
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for proof in proof_cards %}
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<a class="tf-proof-card-link" href="{% url 'proof_card_detail' proof.id %}">
|
||||||
|
<article class="tf-proof-card h-100">
|
||||||
|
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
|
||||||
|
<div class="tf-proof-card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
|
||||||
|
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
|
||||||
|
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
|
||||||
|
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
|
||||||
|
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:110 }}”{% else %}This job is ready to become a published proof asset for the next customer.{% endif %}</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
|
||||||
|
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
|
||||||
|
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}No rating yet{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% if proof.status == 'published' %}
|
||||||
|
<p class="small text-secondary-emphasis mt-3 mb-0">Live at <span class="tf-inline-link">/proof/{{ current_membership.business.slug }}/{{ proof.id }}/</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="tf-empty-state text-center">
|
||||||
|
<h2 class="h4 mb-2">No proof cards yet</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-4">Log a completed job to create the first proof asset in this workspace.</p>
|
||||||
|
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Create first job</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
74
core/templates/core/public_proof_detail.html
Normal file
74
core/templates/core/public_proof_detail.html
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ proof_card.job.service_type }} in {{ proof_card.job.city }} | {{ business.name }} Proof{% endblock %}
|
||||||
|
{% block meta_description %}Published proof from {{ business.name }} for {{ proof_card.job.service_type|lower }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Published proof</div>
|
||||||
|
<h1 class="tf-page-title">{{ proof_card.job.service_type }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}</h1>
|
||||||
|
<p class="tf-page-subtitle">Completed by {{ business.name }} · {{ proof_card.customer_display_name }} · {{ proof_card.job.completed_at|date:"F j, Y" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' business.slug %}">Back to gallery</a>
|
||||||
|
{% if business.google_review_url %}<a class="btn tf-btn tf-btn-primary" href="{{ business.google_review_url }}" target="_blank" rel="noopener">Read more reviews</a>{% elif request.user.is_authenticated and current_membership and current_membership.business_id == business.id %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_detail' proof_card.id %}">Manage this proof card</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<article class="tf-proof-card tf-proof-card-expanded h-100">
|
||||||
|
{% include "core/includes/proof_media_grid.html" with job=proof_card.job grid_class='tf-proof-media-grid-large' %}
|
||||||
|
<div class="tf-proof-card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
|
||||||
|
<span class="tf-card-tag">{{ proof_card.job.service_type }}</span>
|
||||||
|
<span class="tf-badge-verified">{{ proof_card.verified_label }}</span>
|
||||||
|
</div>
|
||||||
|
<blockquote class="tf-testimonial mb-4">{% if proof_card.testimonial_quote %}“{{ proof_card.testimonial_quote }}”{% else %}This published proof card confirms a completed job by {{ business.name }}.{% endif %}</blockquote>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4"><div class="tf-detail-box"><span>Rating</span><strong>{% if proof_card.rating %}★ {{ proof_card.rating }} / 5{% else %}Verified{% endif %}</strong></div></div>
|
||||||
|
<div class="col-md-4"><div class="tf-detail-box"><span>Completed</span><strong>{{ proof_card.job.completed_at|date:"M j, Y" }}</strong></div></div>
|
||||||
|
<div class="col-md-4"><div class="tf-detail-box"><span>Service area</span><strong>{{ proof_card.job.city }}, {{ proof_card.job.state }}</strong></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel mb-4">
|
||||||
|
<h2 class="h4 mb-3">Why this matters</h2>
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
<div class="tf-detail-box"><span>Business</span><strong>{{ business.name }}</strong></div>
|
||||||
|
<div class="tf-detail-box"><span>Customer display</span><strong>{{ proof_card.customer_display_name }}</strong></div>
|
||||||
|
<div class="tf-detail-box"><span>Proof status</span><strong>{{ proof_card.get_status_display }}</strong></div>
|
||||||
|
<div class="tf-detail-box"><span>Placement</span><strong>{{ proof_card.attached_pages|default:'Homepage' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tf-panel">
|
||||||
|
<h2 class="h4 mb-3">More published proof</h2>
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
{% for related in related_proofs %}
|
||||||
|
<a href="{% url 'public_proof_detail' business.slug related.id %}" class="tf-proof-mini">
|
||||||
|
<div>
|
||||||
|
<div class="tf-card-tag mb-2">{{ related.job.service_type }}</div>
|
||||||
|
<strong>{{ related.job.city }}, {{ related.job.state }}</strong>
|
||||||
|
<div class="small text-secondary-emphasis">{{ related.customer_display_name }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="tf-status-pill tf-status-{{ related.status }}">{{ related.get_status_display }}</span>
|
||||||
|
</a>
|
||||||
|
{% empty %}
|
||||||
|
<div class="tf-empty-state text-start">
|
||||||
|
<h3 class="h5 mb-2">This is the first published proof card</h3>
|
||||||
|
<p class="text-secondary-emphasis mb-0">Return to the gallery later as {{ business.name }} publishes more completed work.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
94
core/templates/core/public_proof_gallery.html
Normal file
94
core/templates/core/public_proof_gallery.html
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ business.name }} Proof Gallery | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Browse published proof cards for {{ business.name }} in {{ business.primary_city }}, {{ business.primary_state }}.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Public proof gallery</div>
|
||||||
|
<h1 class="tf-page-title">{{ business.name }} completed work</h1>
|
||||||
|
<p class="tf-page-subtitle">Published proof cards from real completed jobs in {{ business.primary_city }}, {{ business.primary_state }}. Use this gallery to validate the quality, consistency, and trust signal behind the brand.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{% if business.google_review_url %}<a class="btn tf-btn tf-btn-secondary" href="{{ business.google_review_url }}" target="_blank" rel="noopener">Read Google reviews</a>{% endif %}
|
||||||
|
{% if request.user.is_authenticated and current_membership and current_membership.business_id == business.id %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_cards_list' %}">Manage proof cards</a>{% else %}<a class="btn tf-btn tf-btn-primary" href="{% url 'signup' %}">Create your own gallery</a>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if featured_proofs %}
|
||||||
|
<div class="tf-panel mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Featured proof</div>
|
||||||
|
<h2 class="h3 mb-0">Standout published jobs</h2>
|
||||||
|
</div>
|
||||||
|
<div class="small text-secondary-emphasis">{{ proof_cards|length }} published proof card{{ proof_cards|length|pluralize }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for proof in featured_proofs %}
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' business.slug proof.id %}">
|
||||||
|
<article class="tf-proof-card h-100">
|
||||||
|
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
|
||||||
|
<div class="tf-proof-card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
|
||||||
|
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
|
||||||
|
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
|
||||||
|
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
|
||||||
|
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:110 }}”{% else %}Published proof from a real completed job by {{ business.name }}.{% endif %}</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
|
||||||
|
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
|
||||||
|
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}Verified{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
{% for proof in proof_cards %}
|
||||||
|
<div class="col-lg-4 col-md-6">
|
||||||
|
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' business.slug proof.id %}">
|
||||||
|
<article class="tf-proof-card h-100">
|
||||||
|
<div class="tf-proof-media-grid">
|
||||||
|
<div class="tf-photo-slot">Before</div>
|
||||||
|
<div class="tf-photo-slot tf-photo-slot-after">After</div>
|
||||||
|
</div>
|
||||||
|
<div class="tf-proof-card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
|
||||||
|
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
|
||||||
|
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
|
||||||
|
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
|
||||||
|
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:120 }}”{% else %}Published proof asset ready for customers comparing local providers.{% endif %}</p>
|
||||||
|
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
|
||||||
|
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
|
||||||
|
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}Verified{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="tf-empty-state text-center">
|
||||||
|
<h2 class="h4 mb-2">No public proof cards yet</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-4">{{ business.name }} has not published any proof cards yet. Check back soon for verified completed work.</p>
|
||||||
|
<a class="btn tf-btn tf-btn-primary" href="{% url 'signup' %}">Build a proof gallery like this</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
54
core/templates/core/review_request.html
Normal file
54
core/templates/core/review_request.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Share Feedback | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Share feedback on your recent service experience and help create verified proof of work.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="tf-panel tf-panel-centered">
|
||||||
|
<div class="tf-eyebrow">Customer feedback</div>
|
||||||
|
<h1 class="tf-page-title">How was your experience?</h1>
|
||||||
|
<p class="tf-page-subtitle">{{ job.business.name }} completed {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}. Your feedback helps verify real work and guide follow-up.</p>
|
||||||
|
|
||||||
|
{% if submitted %}
|
||||||
|
<div class="tf-empty-state text-center mt-4">
|
||||||
|
{% if positive %}
|
||||||
|
<h2 class="h3 mb-3">Thank you — this proof is ready to help the next customer.</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-4">Your feedback has been saved. The business can now publish this as verified proof of work.</p>
|
||||||
|
{% if redirect_url %}
|
||||||
|
<a href="{{ redirect_url }}" class="btn tf-btn tf-btn-primary">Continue to Google review</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<h2 class="h3 mb-3">Thanks for the feedback.</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-0">This response stays internal so the business can follow up directly and improve the experience.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" class="text-start mt-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="tf-feedback-grid mb-4">
|
||||||
|
{% for radio in form.experience %}
|
||||||
|
<label class="tf-feedback-option">
|
||||||
|
{{ radio.tag }}
|
||||||
|
<span>{{ radio.choice_label }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if form.experience.errors %}<div class="text-danger small mb-3">{{ form.experience.errors|join:', ' }}</div>{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Optional testimonial</label>
|
||||||
|
{{ form.testimonial }}
|
||||||
|
{% if form.testimonial.errors %}<div class="text-danger small mt-1">{{ form.testimonial.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Submit feedback</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
132
core/templates/core/workspace_settings.html
Normal file
132
core/templates/core/workspace_settings.html
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Workspace Settings | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Manage your TrustForge workspace, business profile, and team roles.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<div class="tf-eyebrow">Workspace settings</div>
|
||||||
|
<h1 class="tf-page-title">{{ current_membership.business.name }}</h1>
|
||||||
|
<p class="tf-page-subtitle">Manage the business profile, service territory, review destination, and who has access to this protected trust engine.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn tf-btn tf-btn-secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="h4 mb-2">Business profile</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-0">These details anchor onboarding, workspace identity, and public review routing.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tf-inline-stat">
|
||||||
|
<span>Current role</span>
|
||||||
|
<strong>{{ current_membership.get_role_display }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="row g-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="update_business">
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Business name</label>
|
||||||
|
{{ business_form.name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Industry</label>
|
||||||
|
{{ business_form.industry }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Primary city</label>
|
||||||
|
{{ business_form.primary_city }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">State</label>
|
||||||
|
{{ business_form.primary_state }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Google review URL</label>
|
||||||
|
{{ business_form.google_review_url }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12 pt-2">
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary">Save workspace</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<h2 class="h4 mb-3">Role access model</h2>
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
<div class="tf-role-card"><strong>Owner / Admin</strong><span>Manage workspace settings, team access, jobs, and proof publishing.</span></div>
|
||||||
|
<div class="tf-role-card"><strong>Manager</strong><span>Run the job-to-proof workflow and edit proof cards, but not workspace administration.</span></div>
|
||||||
|
<div class="tf-role-card"><strong>Technician</strong><span>Log completed jobs and view pipeline activity inside the assigned business only.</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4 mt-1">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<h2 class="h4 mb-3">Add team member</h2>
|
||||||
|
<p class="text-secondary-emphasis">Attach a user to this workspace. New users can use the forgot-password flow to activate access if they do not have a password yet.</p>
|
||||||
|
<form method="post" class="row g-3">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="invite_member">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">First name</label>
|
||||||
|
{{ invite_form.first_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Last name</label>
|
||||||
|
{{ invite_form.last_name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Email</label>
|
||||||
|
{{ invite_form.email }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
{{ invite_form.role }}
|
||||||
|
</div>
|
||||||
|
<div class="col-12 pt-2">
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary">Add member</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="tf-panel h-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="h4 mb-0">Workspace team</h2>
|
||||||
|
<span class="tf-card-tag">{{ team_members|length }} seats</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
{% for membership in team_members %}
|
||||||
|
<div class="tf-team-member">
|
||||||
|
<div>
|
||||||
|
<strong>{{ membership.user.get_full_name|default:membership.user.email }}</strong>
|
||||||
|
<div class="small text-secondary-emphasis">{{ membership.user.email }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="tf-status-pill tf-role-pill">{{ membership.get_role_display }}</span>
|
||||||
|
<div class="small text-secondary-emphasis mt-1">Joined {{ membership.created_at|date:"M j, Y" }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div class="tf-empty-state text-start">
|
||||||
|
<h3 class="h5 mb-2">No team members yet</h3>
|
||||||
|
<p class="text-secondary-emphasis mb-0">Invite admins, managers, and technicians to turn this workspace into a real multi-user SaaS account.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
54
core/templates/registration/login.html
Normal file
54
core/templates/registration/login.html
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Login | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Sign in to TrustForge to manage jobs, review requests, and proof cards securely.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5 py-lg-6">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4 align-items-center">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel tf-panel-centered h-100">
|
||||||
|
<div class="tf-eyebrow">Secure access</div>
|
||||||
|
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||||
|
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
|
||||||
|
<div class="tf-auth-points mt-4">
|
||||||
|
<div class="tf-check-field"><div class="tf-check-row"><span>✓</span><div>Access dashboard, jobs, and proof cards securely</div></div></div>
|
||||||
|
<div class="tf-check-field"><div class="tf-check-row"><span>✓</span><div>Preserve the premium TrustForge workflow without exposing customer data publicly</div></div></div>
|
||||||
|
<div class="tf-check-field"><div class="tf-check-row"><span>✓</span><div>Next step adds business onboarding and role-based team access</div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="tf-panel tf-panel-centered tf-auth-card">
|
||||||
|
<div class="tf-card-tag mb-3">Login</div>
|
||||||
|
<h2 class="h3 mb-2">Continue into TrustForge</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-4">Use your work email and password to open the product workspace.</p>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Work email</label>
|
||||||
|
{{ form.username }}
|
||||||
|
{% if form.username.errors %}<div class="text-danger small mt-1">{{ form.username.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<label class="form-label mb-0">Password</label>
|
||||||
|
<a class="tf-inline-link small" href="{% url 'password_reset' %}">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
{{ form.password }}
|
||||||
|
{% if form.password.errors %}<div class="text-danger small mt-1">{{ form.password.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Log in</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-secondary-emphasis small mt-4 mb-0">New to TrustForge? <a class="tf-inline-link" href="{% url 'signup' %}">Create your account</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
21
core/templates/registration/password_reset_complete.html
Normal file
21
core/templates/registration/password_reset_complete.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Password Updated | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Your TrustForge password has been updated successfully.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5 py-lg-6">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7 col-xl-6">
|
||||||
|
<div class="tf-panel tf-panel-centered text-center tf-auth-card">
|
||||||
|
<div class="tf-eyebrow">All set</div>
|
||||||
|
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||||
|
<p class="tf-page-subtitle mx-auto">{{ auth_page_description }}</p>
|
||||||
|
<a class="btn tf-btn tf-btn-primary mt-4" href="{% url 'login' %}">Log in now</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
45
core/templates/registration/password_reset_confirm.html
Normal file
45
core/templates/registration/password_reset_confirm.html
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Set New Password | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Create a new password for your TrustForge account securely.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5 py-lg-6">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7 col-xl-6">
|
||||||
|
<div class="tf-panel tf-panel-centered tf-auth-card">
|
||||||
|
<div class="tf-eyebrow">Create new password</div>
|
||||||
|
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||||
|
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
|
||||||
|
{% if validlink %}
|
||||||
|
<form method="post" novalidate class="mt-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">New password</label>
|
||||||
|
{{ form.new_password1 }}
|
||||||
|
{% if form.new_password1.errors %}<div class="text-danger small mt-1">{{ form.new_password1.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Confirm new password</label>
|
||||||
|
{{ form.new_password2 }}
|
||||||
|
{% if form.new_password2.errors %}<div class="text-danger small mt-1">{{ form.new_password2.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Update password</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="tf-empty-state text-center mt-4">
|
||||||
|
<h2 class="h4 mb-2">Reset link unavailable</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-0">This reset link is invalid or has already been used. Request a fresh one below.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn tf-btn tf-btn-primary mt-4" href="{% url 'password_reset' %}">Request a new link</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
25
core/templates/registration/password_reset_done.html
Normal file
25
core/templates/registration/password_reset_done.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Check Your Email | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Password reset instructions for your TrustForge account have been sent if the email exists.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5 py-lg-6">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7 col-xl-6">
|
||||||
|
<div class="tf-panel tf-panel-centered text-center tf-auth-card">
|
||||||
|
<div class="tf-eyebrow">Email sent</div>
|
||||||
|
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||||
|
<p class="tf-page-subtitle mx-auto">{{ auth_page_description }}</p>
|
||||||
|
<div class="tf-empty-state text-center mt-4">
|
||||||
|
<h2 class="h4 mb-2">Next step</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-0">Open the email from TrustForge and follow the secure link to create a new password.</p>
|
||||||
|
</div>
|
||||||
|
<a class="btn tf-btn tf-btn-secondary mt-4" href="{% url 'login' %}">Return to login</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
10
core/templates/registration/password_reset_email.txt
Normal file
10
core/templates/registration/password_reset_email.txt
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Hi,
|
||||||
|
|
||||||
|
We received a request to reset the password for your TrustForge account.
|
||||||
|
|
||||||
|
Use the link below to choose a new password:
|
||||||
|
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
|
||||||
|
|
||||||
|
If you did not request this, you can ignore this email.
|
||||||
|
|
||||||
|
— TrustForge
|
||||||
33
core/templates/registration/password_reset_form.html
Normal file
33
core/templates/registration/password_reset_form.html
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Forgot Password | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Request a secure password reset link for your TrustForge account.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5 py-lg-6">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-7 col-xl-6">
|
||||||
|
<div class="tf-panel tf-panel-centered tf-auth-card">
|
||||||
|
<div class="tf-eyebrow">Password reset</div>
|
||||||
|
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||||
|
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
|
||||||
|
<form method="post" novalidate class="mt-4">
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Work email</label>
|
||||||
|
{{ form.email }}
|
||||||
|
{% if form.email.errors %}<div class="text-danger small mt-1">{{ form.email.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Send reset link</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-secondary-emphasis small mt-4 mb-0"><a class="tf-inline-link" href="{% url 'login' %}">Back to login</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
1
core/templates/registration/password_reset_subject.txt
Normal file
1
core/templates/registration/password_reset_subject.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
Reset your TrustForge password
|
||||||
67
core/templates/registration/signup.html
Normal file
67
core/templates/registration/signup.html
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Get Started | TrustForge{% endblock %}
|
||||||
|
{% block meta_description %}Create your TrustForge account and start turning completed jobs into proof that wins the next customer.{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="py-5 py-lg-6">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4 align-items-center">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<div class="tf-panel tf-panel-centered h-100">
|
||||||
|
<div class="tf-eyebrow">Get started</div>
|
||||||
|
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||||
|
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
|
||||||
|
<div class="tf-auth-points mt-4">
|
||||||
|
<div class="tf-check-field"><div class="tf-check-row"><span>01</span><div>Create secure email/password access</div></div></div>
|
||||||
|
<div class="tf-check-field"><div class="tf-check-row"><span>02</span><div>Land inside the product instead of a demo-only experience</div></div></div>
|
||||||
|
<div class="tf-check-field"><div class="tf-check-row"><span>03</span><div>Next step will connect your account to a business workspace</div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<div class="tf-panel tf-panel-centered tf-auth-card">
|
||||||
|
<div class="tf-card-tag mb-3">Sign up</div>
|
||||||
|
<h2 class="h3 mb-2">Start your TrustForge workspace</h2>
|
||||||
|
<p class="text-secondary-emphasis mb-4">Create the account that will own your dashboard access and future business onboarding.</p>
|
||||||
|
<form method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">First name</label>
|
||||||
|
{{ form.first_name }}
|
||||||
|
{% if form.first_name.errors %}<div class="text-danger small mt-1">{{ form.first_name.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Last name</label>
|
||||||
|
{{ form.last_name }}
|
||||||
|
{% if form.last_name.errors %}<div class="text-danger small mt-1">{{ form.last_name.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label class="form-label">Work email</label>
|
||||||
|
{{ form.email }}
|
||||||
|
{% if form.email.errors %}<div class="text-danger small mt-1">{{ form.email.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
{{ form.password1 }}
|
||||||
|
{% if form.password1.errors %}<div class="text-danger small mt-1">{{ form.password1.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Confirm password</label>
|
||||||
|
{{ form.password2 }}
|
||||||
|
{% if form.password2.errors %}<div class="text-danger small mt-1">{{ form.password2.errors|join:', ' }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn tf-btn tf-btn-primary w-100 mt-4">Create account</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-secondary-emphasis small mt-4 mb-0">Already have an account? <a class="tf-inline-link" href="{% url 'login' %}">Log in</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
220
core/tests.py
220
core/tests.py
@ -1,3 +1,221 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
# Create your tests here.
|
from .models import Business, BusinessMembership, Customer, Job, JobMedia, ProofCard, ReviewRequest
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class TrustForgeFlowTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username='owner@example.com',
|
||||||
|
email='owner@example.com',
|
||||||
|
password='StrongPass123!',
|
||||||
|
)
|
||||||
|
self.business = Business.objects.create(
|
||||||
|
name='Forge Roofing',
|
||||||
|
slug='forge-roofing',
|
||||||
|
industry='Roofing',
|
||||||
|
primary_city='Austin',
|
||||||
|
primary_state='TX',
|
||||||
|
google_review_url='https://example.com/google-review',
|
||||||
|
)
|
||||||
|
self.membership = BusinessMembership.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
business=self.business,
|
||||||
|
role=BusinessMembership.Role.OWNER,
|
||||||
|
)
|
||||||
|
self.customer = Customer.objects.create(
|
||||||
|
business=self.business,
|
||||||
|
full_name='Jordan Lee',
|
||||||
|
email='jordan@example.com',
|
||||||
|
city='Austin',
|
||||||
|
state='TX',
|
||||||
|
)
|
||||||
|
self.job = Job.objects.create(
|
||||||
|
business=self.business,
|
||||||
|
customer=self.customer,
|
||||||
|
service_type='Roof repair',
|
||||||
|
city='Austin',
|
||||||
|
state='TX',
|
||||||
|
)
|
||||||
|
JobMedia.objects.create(
|
||||||
|
job=self.job,
|
||||||
|
media_type=JobMedia.MediaType.BEFORE,
|
||||||
|
file=SimpleUploadedFile('before-sample.jpg', b'before-image-bytes', content_type='image/jpeg'),
|
||||||
|
)
|
||||||
|
JobMedia.objects.create(
|
||||||
|
job=self.job,
|
||||||
|
media_type=JobMedia.MediaType.AFTER,
|
||||||
|
file=SimpleUploadedFile('after-sample.jpg', b'after-image-bytes', content_type='image/jpeg'),
|
||||||
|
)
|
||||||
|
self.proof_card = ProofCard.objects.create(job=self.job, customer_display_name='Verified homeowner')
|
||||||
|
self.review_request = ReviewRequest.objects.create(job=self.job)
|
||||||
|
|
||||||
|
def test_home_loads(self):
|
||||||
|
response = self.client.get(reverse('home'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'TrustForge')
|
||||||
|
|
||||||
|
def test_dashboard_requires_login(self):
|
||||||
|
response = self.client.get(reverse('dashboard'))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn(reverse('login'), response.url)
|
||||||
|
|
||||||
|
def test_login_with_email_redirects_to_dashboard(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('login'),
|
||||||
|
{'username': 'owner@example.com', 'password': 'StrongPass123!'},
|
||||||
|
)
|
||||||
|
self.assertRedirects(response, reverse('dashboard'))
|
||||||
|
|
||||||
|
def test_logged_in_user_without_membership_redirects_to_onboarding(self):
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username='solo@example.com',
|
||||||
|
email='solo@example.com',
|
||||||
|
password='StrongPass123!',
|
||||||
|
)
|
||||||
|
self.client.force_login(user)
|
||||||
|
response = self.client.get(reverse('dashboard'))
|
||||||
|
self.assertRedirects(response, reverse('business_onboarding'))
|
||||||
|
|
||||||
|
def test_dashboard_only_shows_active_business_data(self):
|
||||||
|
other_business = Business.objects.create(
|
||||||
|
name='Hidden Plumbing',
|
||||||
|
slug='hidden-plumbing',
|
||||||
|
industry='Plumbing',
|
||||||
|
primary_city='Dallas',
|
||||||
|
primary_state='TX',
|
||||||
|
)
|
||||||
|
other_customer = Customer.objects.create(
|
||||||
|
business=other_business,
|
||||||
|
full_name='Taylor Shade',
|
||||||
|
city='Dallas',
|
||||||
|
state='TX',
|
||||||
|
)
|
||||||
|
Job.objects.create(
|
||||||
|
business=other_business,
|
||||||
|
customer=other_customer,
|
||||||
|
service_type='Leak repair',
|
||||||
|
city='Dallas',
|
||||||
|
state='TX',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse('dashboard'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'Forge Roofing proof momentum')
|
||||||
|
self.assertNotContains(response, 'Leak repair')
|
||||||
|
|
||||||
|
def test_technician_cannot_edit_proof_card(self):
|
||||||
|
technician = User.objects.create_user(
|
||||||
|
username='tech@example.com',
|
||||||
|
email='tech@example.com',
|
||||||
|
password='StrongPass123!',
|
||||||
|
)
|
||||||
|
BusinessMembership.objects.create(
|
||||||
|
user=technician,
|
||||||
|
business=self.business,
|
||||||
|
role=BusinessMembership.Role.TECHNICIAN,
|
||||||
|
)
|
||||||
|
self.client.force_login(technician)
|
||||||
|
response = self.client.get(reverse('proof_card_edit', args=[self.proof_card.id]))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_public_review_positive_feedback_publishes_proof(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('review_request', args=[self.review_request.token]),
|
||||||
|
{'experience': 'great', 'testimonial': 'They showed up on time and the roof looks incredible.'},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.proof_card.refresh_from_db()
|
||||||
|
self.assertEqual(self.proof_card.status, 'published')
|
||||||
|
self.assertEqual(self.proof_card.rating, 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_gallery_only_shows_published_cards_for_requested_business(self):
|
||||||
|
self.proof_card.status = ProofCard.Status.PUBLISHED
|
||||||
|
self.proof_card.is_featured = True
|
||||||
|
self.proof_card.testimonial_quote = 'Published proof for Forge Roofing.'
|
||||||
|
self.proof_card.save(update_fields=['status', 'is_featured', 'testimonial_quote'])
|
||||||
|
|
||||||
|
other_business = Business.objects.create(
|
||||||
|
name='Quiet Electric',
|
||||||
|
slug='quiet-electric',
|
||||||
|
industry='Electrical',
|
||||||
|
primary_city='Denver',
|
||||||
|
primary_state='CO',
|
||||||
|
)
|
||||||
|
other_customer = Customer.objects.create(
|
||||||
|
business=other_business,
|
||||||
|
full_name='Morgan Bright',
|
||||||
|
city='Denver',
|
||||||
|
state='CO',
|
||||||
|
)
|
||||||
|
other_job = Job.objects.create(
|
||||||
|
business=other_business,
|
||||||
|
customer=other_customer,
|
||||||
|
service_type='Panel upgrade',
|
||||||
|
city='Denver',
|
||||||
|
state='CO',
|
||||||
|
)
|
||||||
|
ProofCard.objects.create(
|
||||||
|
job=other_job,
|
||||||
|
customer_display_name='Verified homeowner',
|
||||||
|
status=ProofCard.Status.PUBLISHED,
|
||||||
|
testimonial_quote='This should not appear in Forge Roofing gallery.',
|
||||||
|
)
|
||||||
|
|
||||||
|
draft_customer = Customer.objects.create(
|
||||||
|
business=self.business,
|
||||||
|
full_name='Casey Draft',
|
||||||
|
city='Austin',
|
||||||
|
state='TX',
|
||||||
|
)
|
||||||
|
draft_job = Job.objects.create(
|
||||||
|
business=self.business,
|
||||||
|
customer=draft_customer,
|
||||||
|
service_type='Draft-only repair',
|
||||||
|
city='Austin',
|
||||||
|
state='TX',
|
||||||
|
)
|
||||||
|
ProofCard.objects.create(
|
||||||
|
job=draft_job,
|
||||||
|
customer_display_name='Hidden draft',
|
||||||
|
status=ProofCard.Status.DRAFT,
|
||||||
|
testimonial_quote='Draft cards should stay private.',
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(reverse('public_proof_gallery', args=[self.business.slug]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'Forge Roofing completed work')
|
||||||
|
self.assertContains(response, 'Published proof for Forge Roofing.')
|
||||||
|
self.assertContains(response, self.job.before_media.file.url)
|
||||||
|
self.assertContains(response, self.job.after_media.file.url)
|
||||||
|
self.assertNotContains(response, 'This should not appear in Forge Roofing gallery.')
|
||||||
|
self.assertNotContains(response, 'Draft cards should stay private.')
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_proof_detail_renders_uploaded_media(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse('proof_card_detail', args=[self.proof_card.id]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, self.job.before_media.file.url)
|
||||||
|
self.assertContains(response, self.job.after_media.file.url)
|
||||||
|
|
||||||
|
def test_public_proof_detail_requires_published_status(self):
|
||||||
|
response = self.client.get(reverse('public_proof_detail', args=[self.business.slug, self.proof_card.id]))
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
self.proof_card.status = ProofCard.Status.PUBLISHED
|
||||||
|
self.proof_card.testimonial_quote = 'Proof card is now public.'
|
||||||
|
self.proof_card.save(update_fields=['status', 'testimonial_quote'])
|
||||||
|
|
||||||
|
response = self.client.get(reverse('public_proof_detail', args=[self.business.slug, self.proof_card.id]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'Proof card is now public.')
|
||||||
|
self.assertContains(response, self.job.before_media.file.url)
|
||||||
|
self.assertContains(response, self.job.after_media.file.url)
|
||||||
|
|||||||
48
core/urls.py
48
core/urls.py
@ -1,7 +1,51 @@
|
|||||||
|
from django.contrib.auth.views import LogoutView
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import home
|
from .views import (
|
||||||
|
TrustForgeLoginView,
|
||||||
|
TrustForgePasswordResetCompleteView,
|
||||||
|
TrustForgePasswordResetConfirmView,
|
||||||
|
TrustForgePasswordResetDoneView,
|
||||||
|
TrustForgePasswordResetView,
|
||||||
|
business_onboarding,
|
||||||
|
dashboard,
|
||||||
|
home,
|
||||||
|
job_create,
|
||||||
|
job_detail,
|
||||||
|
jobs_list,
|
||||||
|
profile_settings,
|
||||||
|
proof_card_detail,
|
||||||
|
proof_card_edit,
|
||||||
|
proof_cards_list,
|
||||||
|
public_proof_detail,
|
||||||
|
public_proof_gallery,
|
||||||
|
review_request_view,
|
||||||
|
signup,
|
||||||
|
switch_workspace,
|
||||||
|
workspace_settings,
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", home, name="home"),
|
path('', home, name='home'),
|
||||||
|
path('login/', TrustForgeLoginView.as_view(), name='login'),
|
||||||
|
path('signup/', signup, name='signup'),
|
||||||
|
path('logout/', LogoutView.as_view(), name='logout'),
|
||||||
|
path('forgot-password/', TrustForgePasswordResetView.as_view(), name='password_reset'),
|
||||||
|
path('forgot-password/sent/', TrustForgePasswordResetDoneView.as_view(), name='password_reset_done'),
|
||||||
|
path('reset-password/<uidb64>/<token>/', TrustForgePasswordResetConfirmView.as_view(), name='password_reset_confirm'),
|
||||||
|
path('reset-password/complete/', TrustForgePasswordResetCompleteView.as_view(), name='password_reset_complete'),
|
||||||
|
path('onboarding/business/', business_onboarding, name='business_onboarding'),
|
||||||
|
path('workspace/<int:business_id>/switch/', switch_workspace, name='switch_workspace'),
|
||||||
|
path('workspace/settings/', workspace_settings, name='workspace_settings'),
|
||||||
|
path('profile/', profile_settings, name='profile_settings'),
|
||||||
|
path('dashboard/', dashboard, name='dashboard'),
|
||||||
|
path('jobs/', jobs_list, name='jobs_list'),
|
||||||
|
path('jobs/new/', job_create, name='job_create'),
|
||||||
|
path('jobs/<int:job_id>/', job_detail, name='job_detail'),
|
||||||
|
path('proof-cards/', proof_cards_list, name='proof_cards_list'),
|
||||||
|
path('proof-cards/<int:card_id>/', proof_card_detail, name='proof_card_detail'),
|
||||||
|
path('proof-cards/<int:card_id>/edit/', proof_card_edit, name='proof_card_edit'),
|
||||||
|
path('proof/<slug:slug>/', public_proof_gallery, name='public_proof_gallery'),
|
||||||
|
path('proof/<slug:slug>/<int:card_id>/', public_proof_detail, name='public_proof_detail'),
|
||||||
|
path('reviews/<uuid:token>/', review_request_view, name='review_request'),
|
||||||
]
|
]
|
||||||
|
|||||||
734
core/views.py
734
core/views.py
@ -1,25 +1,729 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from django import get_version as django_version
|
from django import get_version as django_version
|
||||||
from django.shortcuts import render
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth import get_user_model, login
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.contrib.auth.views import (
|
||||||
|
LoginView,
|
||||||
|
PasswordResetCompleteView,
|
||||||
|
PasswordResetConfirmView,
|
||||||
|
PasswordResetDoneView,
|
||||||
|
PasswordResetView,
|
||||||
|
)
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
from .forms import (
|
||||||
|
BusinessOnboardingForm,
|
||||||
|
BusinessSettingsForm,
|
||||||
|
JobIntakeForm,
|
||||||
|
ProfileSettingsForm,
|
||||||
|
ProofCardForm,
|
||||||
|
PublicFeedbackForm,
|
||||||
|
SignUpForm,
|
||||||
|
TeamMemberInviteForm,
|
||||||
|
TrustForgeAuthenticationForm,
|
||||||
|
TrustForgePasswordResetForm,
|
||||||
|
TrustForgeSetPasswordForm,
|
||||||
|
)
|
||||||
|
from .models import Business, BusinessMembership, Customer, Feedback, Job, JobMedia, ProofCard, ReviewRequest
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
ACTIVE_BUSINESS_SESSION_KEY = 'trustforge_active_business_id'
|
||||||
|
POSITIVE_EXPERIENCES = {Feedback.Experience.GREAT, Feedback.Experience.GOOD}
|
||||||
|
RATING_MAP = {
|
||||||
|
Feedback.Experience.GREAT: 5,
|
||||||
|
Feedback.Experience.GOOD: 4,
|
||||||
|
Feedback.Experience.OKAY: 3,
|
||||||
|
Feedback.Experience.BAD: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def home(request):
|
def _theme_context() -> dict:
|
||||||
"""Render the landing screen with loader and environment details."""
|
return {
|
||||||
host_name = request.get_host().lower()
|
'project_name': 'TrustForge',
|
||||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
'project_description': (
|
||||||
now = timezone.now()
|
'TrustForge turns completed service jobs into proof cards, testimonials, and conversion assets '
|
||||||
|
'for contractors, HVAC teams, roofers, plumbers, and local service businesses.'
|
||||||
|
),
|
||||||
|
'project_image_url': os.getenv('PROJECT_IMAGE_URL', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ThemedAuthContextMixin:
|
||||||
|
auth_page_title = 'TrustForge Account'
|
||||||
|
auth_page_description = 'Secure access to your proof pipeline, review engine, and published proof assets.'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
context.update(_theme_context())
|
||||||
|
context.setdefault('auth_page_title', self.auth_page_title)
|
||||||
|
context.setdefault('auth_page_description', self.auth_page_description)
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class TrustForgeLoginView(ThemedAuthContextMixin, LoginView):
|
||||||
|
template_name = 'registration/login.html'
|
||||||
|
authentication_form = TrustForgeAuthenticationForm
|
||||||
|
redirect_authenticated_user = True
|
||||||
|
auth_page_title = 'Welcome back to your trust engine'
|
||||||
|
auth_page_description = 'Sign in to manage completed jobs, review requests, proof cards, and every asset that helps the next customer say yes.'
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
redirect_url = self.get_redirect_url()
|
||||||
|
if redirect_url:
|
||||||
|
return redirect_url
|
||||||
|
if _get_active_membership(self.request) is None:
|
||||||
|
return reverse('business_onboarding')
|
||||||
|
return reverse('dashboard')
|
||||||
|
|
||||||
|
|
||||||
|
class TrustForgePasswordResetView(ThemedAuthContextMixin, PasswordResetView):
|
||||||
|
template_name = 'registration/password_reset_form.html'
|
||||||
|
email_template_name = 'registration/password_reset_email.txt'
|
||||||
|
subject_template_name = 'registration/password_reset_subject.txt'
|
||||||
|
success_url = reverse_lazy('password_reset_done')
|
||||||
|
form_class = TrustForgePasswordResetForm
|
||||||
|
auth_page_title = 'Reset your TrustForge password'
|
||||||
|
auth_page_description = 'Enter your work email and we will send a secure reset link so you can get back into your proof pipeline.'
|
||||||
|
|
||||||
|
|
||||||
|
class TrustForgePasswordResetDoneView(ThemedAuthContextMixin, PasswordResetDoneView):
|
||||||
|
template_name = 'registration/password_reset_done.html'
|
||||||
|
auth_page_title = 'Check your email'
|
||||||
|
auth_page_description = 'If that email is tied to an account, a secure reset link is on its way.'
|
||||||
|
|
||||||
|
|
||||||
|
class TrustForgePasswordResetConfirmView(ThemedAuthContextMixin, PasswordResetConfirmView):
|
||||||
|
template_name = 'registration/password_reset_confirm.html'
|
||||||
|
form_class = TrustForgeSetPasswordForm
|
||||||
|
success_url = reverse_lazy('password_reset_complete')
|
||||||
|
auth_page_title = 'Create a new password'
|
||||||
|
auth_page_description = 'Set a new password for your account and return to the TrustForge dashboard securely.'
|
||||||
|
|
||||||
|
|
||||||
|
class TrustForgePasswordResetCompleteView(ThemedAuthContextMixin, PasswordResetCompleteView):
|
||||||
|
template_name = 'registration/password_reset_complete.html'
|
||||||
|
auth_page_title = 'Password updated'
|
||||||
|
auth_page_description = 'Your password has been changed successfully. You can sign back into TrustForge now.'
|
||||||
|
|
||||||
|
|
||||||
|
def _get_memberships_queryset(user):
|
||||||
|
return BusinessMembership.objects.select_related('business').filter(user=user, business__is_active=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_memberships(request: HttpRequest) -> list[BusinessMembership]:
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return []
|
||||||
|
cached = getattr(request, '_trustforge_memberships', None)
|
||||||
|
if cached is None:
|
||||||
|
cached = list(_get_memberships_queryset(request.user))
|
||||||
|
request._trustforge_memberships = cached
|
||||||
|
return cached
|
||||||
|
|
||||||
|
|
||||||
|
def _get_active_membership(request: HttpRequest) -> BusinessMembership | None:
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return None
|
||||||
|
cached = getattr(request, '_trustforge_active_membership', None)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
memberships = _get_user_memberships(request)
|
||||||
|
active_business_id = request.session.get(ACTIVE_BUSINESS_SESSION_KEY)
|
||||||
|
membership = next((item for item in memberships if item.business_id == active_business_id), None)
|
||||||
|
if membership is None and memberships:
|
||||||
|
membership = memberships[0]
|
||||||
|
request._trustforge_active_membership = membership
|
||||||
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
def _set_active_membership(request: HttpRequest, business_id: int) -> None:
|
||||||
|
request.session[ACTIVE_BUSINESS_SESSION_KEY] = business_id
|
||||||
|
request._trustforge_active_membership = None
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_unique_business_slug(name: str) -> str:
|
||||||
|
base_slug = slugify(name)[:45] or 'business'
|
||||||
|
candidate = base_slug
|
||||||
|
counter = 2
|
||||||
|
while Business.objects.filter(slug=candidate).exists():
|
||||||
|
candidate = f'{base_slug}-{counter}'[:55]
|
||||||
|
counter += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def business_required(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapped(request: HttpRequest, *args, **kwargs):
|
||||||
|
if _get_active_membership(request) is None:
|
||||||
|
messages.info(request, 'Create or join a business workspace to unlock your protected TrustForge pipeline.')
|
||||||
|
return redirect('business_onboarding')
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
def membership_role_required(*allowed_roles: str):
|
||||||
|
def decorator(view_func):
|
||||||
|
@wraps(view_func)
|
||||||
|
def wrapped(request: HttpRequest, *args, **kwargs):
|
||||||
|
membership = _get_active_membership(request)
|
||||||
|
if membership is None:
|
||||||
|
messages.info(request, 'Create or join a business workspace to continue.')
|
||||||
|
return redirect('business_onboarding')
|
||||||
|
if membership.role not in allowed_roles:
|
||||||
|
raise PermissionDenied('Your role does not allow this action in the current workspace.')
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _build_review_link(request: HttpRequest, review_request: ReviewRequest) -> str:
|
||||||
|
return request.build_absolute_uri(reverse('review_request', args=[str(review_request.token)]))
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def _create_review_request(request: HttpRequest, job: Job, channel: str) -> ReviewRequest:
|
||||||
|
review_request, created = ReviewRequest.objects.get_or_create(
|
||||||
|
job=job,
|
||||||
|
defaults={
|
||||||
|
'channel': channel,
|
||||||
|
'status': ReviewRequest.Status.SENT,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
review_request.delivery_note = 'Share this link manually from the field app.'
|
||||||
|
if channel == ReviewRequest.Channel.EMAIL and job.customer.email:
|
||||||
|
review_link = _build_review_link(request, review_request)
|
||||||
|
email_sent = send_mail(
|
||||||
|
subject=f'How was your {job.service_type.lower()} experience?',
|
||||||
|
message=(
|
||||||
|
f'Hi {job.customer.full_name}\n\n'
|
||||||
|
f'Thanks for choosing {job.business.name}. Please share your feedback here: {review_link}\n\n'
|
||||||
|
'Your response helps us build verified proof of work for future customers.'
|
||||||
|
),
|
||||||
|
from_email=None,
|
||||||
|
recipient_list=[job.customer.email],
|
||||||
|
fail_silently=True,
|
||||||
|
)
|
||||||
|
review_request.delivery_note = 'Email sent automatically.' if email_sent else 'Email backend unavailable — copy link manually.'
|
||||||
|
review_request.status = ReviewRequest.Status.SENT
|
||||||
|
review_request.sent_at = timezone.now()
|
||||||
|
review_request.save()
|
||||||
|
job.status = Job.Status.REVIEW_REQUESTED
|
||||||
|
job.save(update_fields=['status'])
|
||||||
|
return review_request
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def signup(request: HttpRequest) -> HttpResponse:
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return redirect('dashboard' if _get_active_membership(request) else 'business_onboarding')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = SignUpForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
user = form.save()
|
||||||
|
login(request, user)
|
||||||
|
messages.success(request, 'Your TrustForge account is ready. Now let’s create your first business workspace.')
|
||||||
|
return redirect('business_onboarding')
|
||||||
|
else:
|
||||||
|
form = SignUpForm()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"project_name": "New Style",
|
**_theme_context(),
|
||||||
"agent_brand": agent_brand,
|
'auth_page_title': 'Create your TrustForge account',
|
||||||
"django_version": django_version(),
|
'auth_page_description': 'Start with secure account access, then connect your business workspace, team roles, and protected proof pipeline.',
|
||||||
"python_version": platform.python_version(),
|
'form': form,
|
||||||
"current_time": now,
|
|
||||||
"host_name": host_name,
|
|
||||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
|
||||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
|
||||||
}
|
}
|
||||||
return render(request, "core/index.html", context)
|
return render(request, 'registration/signup.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@transaction.atomic
|
||||||
|
def business_onboarding(request: HttpRequest) -> HttpResponse:
|
||||||
|
current_membership = _get_active_membership(request)
|
||||||
|
if current_membership is not None:
|
||||||
|
return redirect('dashboard')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = BusinessOnboardingForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
business = form.save(commit=False)
|
||||||
|
business.slug = _generate_unique_business_slug(business.name)
|
||||||
|
business.save()
|
||||||
|
membership = BusinessMembership.objects.create(
|
||||||
|
business=business,
|
||||||
|
user=request.user,
|
||||||
|
role=BusinessMembership.Role.OWNER,
|
||||||
|
)
|
||||||
|
_set_active_membership(request, membership.business_id)
|
||||||
|
messages.success(request, 'Workspace created. Your jobs, proof cards, and reviews are now scoped to this business.')
|
||||||
|
return redirect('dashboard')
|
||||||
|
else:
|
||||||
|
form = BusinessOnboardingForm()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**_theme_context(),
|
||||||
|
'form': form,
|
||||||
|
}
|
||||||
|
return render(request, 'core/business_onboarding.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@transaction.atomic
|
||||||
|
def switch_workspace(request: HttpRequest, business_id: int) -> HttpResponse:
|
||||||
|
membership = get_object_or_404(_get_memberships_queryset(request.user), business_id=business_id)
|
||||||
|
_set_active_membership(request, membership.business_id)
|
||||||
|
messages.success(request, f'Workspace switched to {membership.business.name}.')
|
||||||
|
next_url = request.POST.get('next') or reverse('dashboard')
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@transaction.atomic
|
||||||
|
def profile_settings(request: HttpRequest) -> HttpResponse:
|
||||||
|
current_membership = _get_active_membership(request)
|
||||||
|
memberships = _get_user_memberships(request)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ProfileSettingsForm(request.POST, instance=request.user)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Profile settings updated.')
|
||||||
|
return redirect('profile_settings')
|
||||||
|
else:
|
||||||
|
form = ProfileSettingsForm(instance=request.user)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**_theme_context(),
|
||||||
|
'form': form,
|
||||||
|
'current_membership': current_membership,
|
||||||
|
'memberships': memberships,
|
||||||
|
}
|
||||||
|
return render(request, 'core/profile_settings.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@membership_role_required(BusinessMembership.Role.OWNER, BusinessMembership.Role.ADMIN)
|
||||||
|
@transaction.atomic
|
||||||
|
def workspace_settings(request: HttpRequest) -> HttpResponse:
|
||||||
|
current_membership = _get_active_membership(request)
|
||||||
|
business = current_membership.business
|
||||||
|
team_members = BusinessMembership.objects.select_related('user').filter(business=business).order_by('created_at', 'id')
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
action = request.POST.get('action')
|
||||||
|
if action == 'update_business':
|
||||||
|
business_form = BusinessSettingsForm(request.POST, instance=business)
|
||||||
|
invite_form = TeamMemberInviteForm()
|
||||||
|
if business_form.is_valid():
|
||||||
|
business_form.save()
|
||||||
|
messages.success(request, 'Workspace settings updated.')
|
||||||
|
return redirect('workspace_settings')
|
||||||
|
elif action == 'invite_member':
|
||||||
|
business_form = BusinessSettingsForm(instance=business)
|
||||||
|
invite_form = TeamMemberInviteForm(request.POST)
|
||||||
|
if invite_form.is_valid():
|
||||||
|
email = invite_form.cleaned_data['email']
|
||||||
|
user, created = User.objects.get_or_create(
|
||||||
|
email=email,
|
||||||
|
defaults={
|
||||||
|
'username': email,
|
||||||
|
'email': email,
|
||||||
|
'first_name': invite_form.cleaned_data.get('first_name', '').strip(),
|
||||||
|
'last_name': invite_form.cleaned_data.get('last_name', '').strip(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save(update_fields=['password'])
|
||||||
|
else:
|
||||||
|
updated_fields = []
|
||||||
|
first_name = invite_form.cleaned_data.get('first_name', '').strip()
|
||||||
|
last_name = invite_form.cleaned_data.get('last_name', '').strip()
|
||||||
|
if first_name and not user.first_name:
|
||||||
|
user.first_name = first_name
|
||||||
|
updated_fields.append('first_name')
|
||||||
|
if last_name and not user.last_name:
|
||||||
|
user.last_name = last_name
|
||||||
|
updated_fields.append('last_name')
|
||||||
|
if updated_fields:
|
||||||
|
user.save(update_fields=updated_fields)
|
||||||
|
|
||||||
|
membership, membership_created = BusinessMembership.objects.update_or_create(
|
||||||
|
business=business,
|
||||||
|
user=user,
|
||||||
|
defaults={'role': invite_form.cleaned_data['role']},
|
||||||
|
)
|
||||||
|
if membership_created or created:
|
||||||
|
messages.success(request, 'Team member added. If this is a brand-new user, they can use “Forgot password” to set access.')
|
||||||
|
else:
|
||||||
|
messages.success(request, 'Team member role updated for this workspace.')
|
||||||
|
return redirect('workspace_settings')
|
||||||
|
else:
|
||||||
|
business_form = BusinessSettingsForm(instance=business)
|
||||||
|
invite_form = TeamMemberInviteForm()
|
||||||
|
else:
|
||||||
|
business_form = BusinessSettingsForm(instance=business)
|
||||||
|
invite_form = TeamMemberInviteForm()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**_theme_context(),
|
||||||
|
'business_form': business_form,
|
||||||
|
'invite_form': invite_form,
|
||||||
|
'current_membership': current_membership,
|
||||||
|
'team_members': team_members,
|
||||||
|
}
|
||||||
|
return render(request, 'core/workspace_settings.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def home(request: HttpRequest) -> HttpResponse:
|
||||||
|
businesses = Business.objects.count()
|
||||||
|
stats = Job.objects.aggregate(
|
||||||
|
completed_jobs=Count('id'),
|
||||||
|
review_requests=Count('review_request'),
|
||||||
|
proof_cards=Count('proof_card'),
|
||||||
|
published_proof=Count('proof_card', filter=Q(proof_card__status=ProofCard.Status.PUBLISHED)),
|
||||||
|
)
|
||||||
|
featured_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
|
||||||
|
is_featured=True,
|
||||||
|
status=ProofCard.Status.PUBLISHED,
|
||||||
|
job__business__is_active=True,
|
||||||
|
)[:3]
|
||||||
|
recent_jobs = Job.objects.select_related('customer', 'business').prefetch_related('media')[:4]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**_theme_context(),
|
||||||
|
'django_version': django_version(),
|
||||||
|
'python_version': platform.python_version(),
|
||||||
|
'current_time': timezone.now(),
|
||||||
|
'business_count': businesses,
|
||||||
|
'stats': stats,
|
||||||
|
'featured_proofs': featured_proofs,
|
||||||
|
'recent_jobs': recent_jobs,
|
||||||
|
}
|
||||||
|
return render(request, 'core/index.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def public_proof_gallery(request: HttpRequest, slug: str) -> HttpResponse:
|
||||||
|
business = get_object_or_404(Business, slug=slug, is_active=True)
|
||||||
|
proof_cards = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
|
||||||
|
job__business=business,
|
||||||
|
status=ProofCard.Status.PUBLISHED,
|
||||||
|
)
|
||||||
|
featured_proofs = proof_cards.filter(is_featured=True)[:3]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**_theme_context(),
|
||||||
|
'business': business,
|
||||||
|
'proof_cards': proof_cards,
|
||||||
|
'featured_proofs': featured_proofs,
|
||||||
|
}
|
||||||
|
return render(request, 'core/public_proof_gallery.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def public_proof_detail(request: HttpRequest, slug: str, card_id: int) -> HttpResponse:
|
||||||
|
proof_card = get_object_or_404(
|
||||||
|
ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'),
|
||||||
|
id=card_id,
|
||||||
|
job__business__slug=slug,
|
||||||
|
job__business__is_active=True,
|
||||||
|
status=ProofCard.Status.PUBLISHED,
|
||||||
|
)
|
||||||
|
related_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
|
||||||
|
job__business=proof_card.job.business,
|
||||||
|
status=ProofCard.Status.PUBLISHED,
|
||||||
|
).exclude(id=proof_card.id)[:3]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**_theme_context(),
|
||||||
|
'business': proof_card.job.business,
|
||||||
|
'proof_card': proof_card,
|
||||||
|
'related_proofs': related_proofs,
|
||||||
|
}
|
||||||
|
return render(request, 'core/public_proof_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@business_required
|
||||||
|
@transaction.atomic
|
||||||
|
def dashboard(request: HttpRequest) -> HttpResponse:
|
||||||
|
current_membership = _get_active_membership(request)
|
||||||
|
business = current_membership.business
|
||||||
|
jobs = Job.objects.filter(business=business)
|
||||||
|
stats = jobs.aggregate(
|
||||||
|
completed_jobs=Count('id'),
|
||||||
|
review_requests=Count('review_request'),
|
||||||
|
proof_cards=Count('proof_card'),
|
||||||
|
published_cards=Count('proof_card', filter=Q(proof_card__status=ProofCard.Status.PUBLISHED)),
|
||||||
|
)
|
||||||
|
feedback_qs = Feedback.objects.filter(review_request__job__business=business)
|
||||||
|
positive_feedback = feedback_qs.filter(experience__in=POSITIVE_EXPERIENCES).count()
|
||||||
|
total_feedback = feedback_qs.count()
|
||||||
|
conversion_rate = round((positive_feedback / total_feedback) * 100, 1) if total_feedback else 0
|
||||||
|
|
||||||
|
recent_jobs = jobs.select_related('customer', 'business').prefetch_related('media')[:5]
|
||||||
|
recent_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(job__business=business)[:4]
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**_theme_context(),
|
||||||
|
'current_membership': current_membership,
|
||||||
|
'stats': stats,
|
||||||
|
'conversion_rate': conversion_rate,
|
||||||
|
'recent_jobs': recent_jobs,
|
||||||
|
'recent_proofs': recent_proofs,
|
||||||
|
}
|
||||||
|
return render(request, 'core/dashboard.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@business_required
|
||||||
|
@transaction.atomic
|
||||||
|
def jobs_list(request: HttpRequest) -> HttpResponse:
|
||||||
|
current_membership = _get_active_membership(request)
|
||||||
|
jobs = Job.objects.select_related('customer', 'business').prefetch_related('media').filter(business=current_membership.business)
|
||||||
|
context = {**_theme_context(), 'jobs': jobs, 'current_membership': current_membership}
|
||||||
|
return render(request, 'core/jobs_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@business_required
|
||||||
|
@transaction.atomic
|
||||||
|
def job_create(request: HttpRequest) -> HttpResponse:
|
||||||
|
current_membership = _get_active_membership(request)
|
||||||
|
business = current_membership.business
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = JobIntakeForm(request.POST, request.FILES, business=business)
|
||||||
|
if form.is_valid():
|
||||||
|
customer = Customer.objects.create(
|
||||||
|
business=business,
|
||||||
|
full_name=form.cleaned_data['customer_name'],
|
||||||
|
email=form.cleaned_data['customer_email'],
|
||||||
|
phone=form.cleaned_data['customer_phone'],
|
||||||
|
city=form.cleaned_data['customer_city'],
|
||||||
|
state=form.cleaned_data['customer_state'],
|
||||||
|
)
|
||||||
|
job = Job.objects.create(
|
||||||
|
business=business,
|
||||||
|
customer=customer,
|
||||||
|
service_type=form.cleaned_data['service_type'],
|
||||||
|
description=form.cleaned_data['description'],
|
||||||
|
technician_name=form.cleaned_data['technician_name'],
|
||||||
|
city=form.cleaned_data['customer_city'],
|
||||||
|
state=form.cleaned_data['customer_state'],
|
||||||
|
completed_at=form.cleaned_data['completion_date'],
|
||||||
|
project_value=form.cleaned_data['project_value'],
|
||||||
|
status=Job.Status.COMPLETED,
|
||||||
|
)
|
||||||
|
for media_type, upload in (
|
||||||
|
(JobMedia.MediaType.BEFORE, form.cleaned_data.get('before_photo')),
|
||||||
|
(JobMedia.MediaType.AFTER, form.cleaned_data.get('after_photo')),
|
||||||
|
):
|
||||||
|
if upload:
|
||||||
|
JobMedia.objects.create(job=job, media_type=media_type, file=upload)
|
||||||
|
|
||||||
|
display_name = 'Verified homeowner' if form.cleaned_data['anonymize_customer'] else customer.full_name
|
||||||
|
ProofCard.objects.create(
|
||||||
|
job=job,
|
||||||
|
customer_display_name=display_name,
|
||||||
|
is_anonymized=form.cleaned_data['anonymize_customer'],
|
||||||
|
attached_widget_label='Homepage proof gallery',
|
||||||
|
attached_pages='Homepage, Service pages',
|
||||||
|
status=ProofCard.Status.DRAFT,
|
||||||
|
)
|
||||||
|
|
||||||
|
if form.cleaned_data['send_review_request']:
|
||||||
|
_create_review_request(request, job, form.cleaned_data['review_channel'])
|
||||||
|
|
||||||
|
messages.success(request, 'Job logged inside your workspace. Proof card drafted and ready for review workflow.')
|
||||||
|
return redirect('job_detail', job_id=job.id)
|
||||||
|
else:
|
||||||
|
form = JobIntakeForm(
|
||||||
|
business=business,
|
||||||
|
initial={
|
||||||
|
'business': business,
|
||||||
|
'customer_city': business.primary_city,
|
||||||
|
'customer_state': business.primary_state,
|
||||||
|
'technician_name': request.user.get_full_name(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {**_theme_context(), 'form': form, 'current_membership': current_membership}
|
||||||
|
return render(request, 'core/job_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@business_required
|
||||||
|
@transaction.atomic
|
||||||
|
def job_detail(request: HttpRequest, job_id: int) -> HttpResponse:
|
||||||
|
current_membership = _get_active_membership(request)
|
||||||
|
job = get_object_or_404(
|
||||||
|
Job.objects.select_related('customer', 'business', 'proof_card', 'review_request').prefetch_related('media'),
|
||||||
|
id=job_id,
|
||||||
|
business=current_membership.business,
|
||||||
|
)
|
||||||
|
if request.method == 'POST' and request.POST.get('action') == 'send_review_request':
|
||||||
|
channel = request.POST.get('channel', ReviewRequest.Channel.EMAIL)
|
||||||
|
review_request = _create_review_request(request, job, channel)
|
||||||
|
messages.success(request, f'Review request sent. Share link: {_build_review_link(request, review_request)}')
|
||||||
|
return redirect('job_detail', job_id=job.id)
|
||||||
|
|
||||||
|
context = {**_theme_context(), 'job': job, 'current_membership': current_membership}
|
||||||
|
return render(request, 'core/job_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@business_required
|
||||||
|
@transaction.atomic
|
||||||
|
def proof_cards_list(request: HttpRequest) -> HttpResponse:
|
||||||
|
current_membership = _get_active_membership(request)
|
||||||
|
proof_cards = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
|
||||||
|
job__business=current_membership.business
|
||||||
|
)
|
||||||
|
context = {**_theme_context(), 'proof_cards': proof_cards, 'current_membership': current_membership}
|
||||||
|
return render(request, 'core/proof_cards_list.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@business_required
|
||||||
|
@transaction.atomic
|
||||||
|
def proof_card_detail(request: HttpRequest, card_id: int) -> HttpResponse:
|
||||||
|
current_membership = _get_active_membership(request)
|
||||||
|
proof_card = get_object_or_404(
|
||||||
|
ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'),
|
||||||
|
id=card_id,
|
||||||
|
job__business=current_membership.business,
|
||||||
|
)
|
||||||
|
if request.method == 'POST':
|
||||||
|
if not current_membership.can_manage_proof:
|
||||||
|
raise PermissionDenied('Your role does not allow proof publishing controls.')
|
||||||
|
action = request.POST.get('action')
|
||||||
|
if action == 'publish':
|
||||||
|
proof_card.status = ProofCard.Status.PUBLISHED
|
||||||
|
proof_card.published_at = timezone.now()
|
||||||
|
proof_card.save(update_fields=['status', 'published_at', 'updated_at'])
|
||||||
|
proof_card.job.status = Job.Status.PROOF_READY
|
||||||
|
proof_card.job.save(update_fields=['status'])
|
||||||
|
messages.success(request, 'Proof card published to the trust gallery.')
|
||||||
|
elif action == 'hide':
|
||||||
|
proof_card.status = ProofCard.Status.HIDDEN
|
||||||
|
proof_card.save(update_fields=['status', 'updated_at'])
|
||||||
|
messages.success(request, 'Proof card hidden from public display.')
|
||||||
|
elif action == 'toggle_featured':
|
||||||
|
proof_card.is_featured = not proof_card.is_featured
|
||||||
|
proof_card.save(update_fields=['is_featured', 'updated_at'])
|
||||||
|
messages.success(request, 'Featured flag updated.')
|
||||||
|
return redirect('proof_card_detail', card_id=proof_card.id)
|
||||||
|
|
||||||
|
context = {**_theme_context(), 'proof_card': proof_card, 'current_membership': current_membership}
|
||||||
|
return render(request, 'core/proof_card_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@membership_role_required(BusinessMembership.Role.OWNER, BusinessMembership.Role.ADMIN, BusinessMembership.Role.MANAGER)
|
||||||
|
@transaction.atomic
|
||||||
|
def proof_card_edit(request: HttpRequest, card_id: int) -> HttpResponse:
|
||||||
|
current_membership = _get_active_membership(request)
|
||||||
|
proof_card = get_object_or_404(
|
||||||
|
ProofCard.objects.select_related('job__customer', 'job__business'),
|
||||||
|
id=card_id,
|
||||||
|
job__business=current_membership.business,
|
||||||
|
)
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = ProofCardForm(request.POST, instance=proof_card)
|
||||||
|
if form.is_valid():
|
||||||
|
proof_card = form.save(commit=False)
|
||||||
|
if proof_card.status == ProofCard.Status.PUBLISHED and not proof_card.published_at:
|
||||||
|
proof_card.published_at = timezone.now()
|
||||||
|
proof_card.save()
|
||||||
|
messages.success(request, 'Proof card updated.')
|
||||||
|
return redirect('proof_card_detail', card_id=proof_card.id)
|
||||||
|
else:
|
||||||
|
form = ProofCardForm(instance=proof_card)
|
||||||
|
|
||||||
|
context = {**_theme_context(), 'form': form, 'proof_card': proof_card, 'current_membership': current_membership}
|
||||||
|
return render(request, 'core/proof_card_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def review_request_view(request: HttpRequest, token: str) -> HttpResponse:
|
||||||
|
review_request = get_object_or_404(
|
||||||
|
ReviewRequest.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'),
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
proof_card = review_request.job.proof_card
|
||||||
|
if review_request.status == ReviewRequest.Status.SENT:
|
||||||
|
review_request.status = ReviewRequest.Status.VIEWED
|
||||||
|
review_request.last_opened_at = timezone.now()
|
||||||
|
review_request.save(update_fields=['status', 'last_opened_at'])
|
||||||
|
|
||||||
|
submitted = False
|
||||||
|
positive = False
|
||||||
|
redirect_url = review_request.job.business.google_review_url
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = PublicFeedbackForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
experience = form.cleaned_data['experience']
|
||||||
|
testimonial = form.cleaned_data['testimonial'].strip()
|
||||||
|
positive = experience in POSITIVE_EXPERIENCES
|
||||||
|
feedback, _ = Feedback.objects.update_or_create(
|
||||||
|
review_request=review_request,
|
||||||
|
defaults={
|
||||||
|
'experience': experience,
|
||||||
|
'rating': RATING_MAP[experience],
|
||||||
|
'testimonial': testimonial,
|
||||||
|
'follow_up_required': not positive,
|
||||||
|
'is_public_approved': positive,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
review_request.status = ReviewRequest.Status.RESPONDED
|
||||||
|
review_request.reviewed_at = timezone.now()
|
||||||
|
review_request.save(update_fields=['status', 'reviewed_at'])
|
||||||
|
|
||||||
|
proof_card.rating = feedback.rating
|
||||||
|
proof_card.testimonial_quote = testimonial
|
||||||
|
if positive:
|
||||||
|
proof_card.status = ProofCard.Status.PUBLISHED
|
||||||
|
proof_card.published_at = timezone.now()
|
||||||
|
else:
|
||||||
|
proof_card.status = ProofCard.Status.DRAFT
|
||||||
|
proof_card.save(update_fields=['rating', 'testimonial_quote', 'status', 'published_at', 'updated_at'])
|
||||||
|
review_request.job.status = Job.Status.PROOF_READY if positive else Job.Status.REVIEW_REQUESTED
|
||||||
|
review_request.job.save(update_fields=['status'])
|
||||||
|
submitted = True
|
||||||
|
form = PublicFeedbackForm()
|
||||||
|
else:
|
||||||
|
form = PublicFeedbackForm()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**_theme_context(),
|
||||||
|
'review_request': review_request,
|
||||||
|
'job': review_request.job,
|
||||||
|
'proof_card': proof_card,
|
||||||
|
'form': form,
|
||||||
|
'submitted': submitted,
|
||||||
|
'positive': positive,
|
||||||
|
'redirect_url': redirect_url,
|
||||||
|
}
|
||||||
|
return render(request, 'core/review_request.html', context)
|
||||||
|
|||||||
@ -1,4 +1,840 @@
|
|||||||
/* Custom styles for the application */
|
/* TrustForge design system */
|
||||||
body {
|
:root {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
--tf-bg: #f4f7f6;
|
||||||
|
--tf-surface: rgba(255, 255, 255, 0.82);
|
||||||
|
--tf-surface-strong: #ffffff;
|
||||||
|
--tf-surface-dark: #0f172a;
|
||||||
|
--tf-border: rgba(15, 23, 42, 0.08);
|
||||||
|
--tf-primary: #0f766e;
|
||||||
|
--tf-primary-deep: #115e59;
|
||||||
|
--tf-secondary: #1e293b;
|
||||||
|
--tf-accent: #f97316;
|
||||||
|
--tf-accent-soft: #fff1e8;
|
||||||
|
--tf-success: #15803d;
|
||||||
|
--tf-text: #0f172a;
|
||||||
|
--tf-muted: #64748b;
|
||||||
|
--tf-shadow: 0 20px 60px rgba(15, 23, 42, 0.10);
|
||||||
|
--tf-shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||||
|
--tf-radius-xl: 28px;
|
||||||
|
--tf-radius-lg: 22px;
|
||||||
|
--tf-radius-md: 16px;
|
||||||
|
--tf-spacing: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.trustforge-body {
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
color: var(--tf-text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 35%),
|
||||||
|
radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 28%),
|
||||||
|
linear-gradient(180deg, #fbfcfb 0%, var(--tf-bg) 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, .navbar-brand, .tf-display, .tf-section-title, .tf-page-title {
|
||||||
|
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-background-glow {
|
||||||
|
position: fixed;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(60px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-background-glow-1 {
|
||||||
|
width: 320px;
|
||||||
|
height: 320px;
|
||||||
|
background: rgba(15, 118, 110, 0.12);
|
||||||
|
top: 10%;
|
||||||
|
left: -4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-background-glow-2 {
|
||||||
|
width: 360px;
|
||||||
|
height: 360px;
|
||||||
|
background: rgba(249, 115, 22, 0.10);
|
||||||
|
right: -6%;
|
||||||
|
top: 12%;
|
||||||
|
}
|
||||||
|
|
||||||
|
main, .tf-site-header, .tf-footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-lg-6 {
|
||||||
|
padding-top: 5rem !important;
|
||||||
|
padding-bottom: 5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-navbar {
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.68);
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-brand-mark {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--tf-primary), #2dd4bf);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-nav-toggle {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-navbar .nav-link {
|
||||||
|
color: var(--tf-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.6rem 0.95rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-navbar .nav-link:hover,
|
||||||
|
.tf-navbar .nav-link:focus {
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.85rem 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: var(--tf-shadow-soft);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn:hover,
|
||||||
|
.tf-btn:focus {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--tf-primary), #14b8a6);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn-primary:hover,
|
||||||
|
.tf-btn-primary:focus {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-alert {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.12);
|
||||||
|
background: rgba(236, 253, 245, 0.88);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
box-shadow: var(--tf-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-hero-section {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
padding: 0.5rem 0.95rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-eyebrow-light {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-display {
|
||||||
|
font-size: clamp(2.75rem, 7vw, 5.2rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
max-width: 13ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-lead,
|
||||||
|
.tf-page-subtitle {
|
||||||
|
color: var(--tf-muted);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-page-title {
|
||||||
|
font-size: clamp(2rem, 4vw, 3.3rem);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-section-title {
|
||||||
|
font-size: clamp(1.75rem, 3.2vw, 2.6rem);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-stat-row span,
|
||||||
|
.tf-metric-card span {
|
||||||
|
display: block;
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-stat-chip,
|
||||||
|
.tf-metric-card {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: var(--tf-radius-md);
|
||||||
|
padding: 1.1rem 1rem;
|
||||||
|
box-shadow: var(--tf-shadow-soft);
|
||||||
|
color: var(--tf-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-card,
|
||||||
|
.tf-panel,
|
||||||
|
.tf-proof-card,
|
||||||
|
.tf-empty-state {
|
||||||
|
background: var(--tf-surface);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.75);
|
||||||
|
border-radius: var(--tf-radius-xl);
|
||||||
|
box-shadow: var(--tf-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
background: linear-gradient(135deg, rgba(249, 115, 22, 0.18), transparent);
|
||||||
|
border-radius: 36px;
|
||||||
|
right: -30px;
|
||||||
|
bottom: -40px;
|
||||||
|
transform: rotate(18deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-header span {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-mini-step,
|
||||||
|
.tf-activity-row,
|
||||||
|
.tf-proof-mini {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-mini-step.active {
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-preview,
|
||||||
|
.tf-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-panel-dark {
|
||||||
|
background: linear-gradient(135deg, #0f172a, #1e293b);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-panel-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(15, 118, 110, 0.10);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-media-grid,
|
||||||
|
.tf-proof-media-grid-large,
|
||||||
|
.tf-proof-media-grid-static {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-media-grid-static {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-slot {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 170px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0)),
|
||||||
|
linear-gradient(135deg, rgba(15, 23, 42, 0.88), rgba(15, 118, 110, 0.82));
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: start;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-slot::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0.56));
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-slot-after {
|
||||||
|
background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-slot-has-media {
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-image {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.42);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-empty-proof {
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(248, 250, 252, 0.72);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-card-link {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-card-body {
|
||||||
|
padding: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-card-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-badge-verified,
|
||||||
|
.tf-rating-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.42rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-badge-verified {
|
||||||
|
background: rgba(21, 128, 61, 0.10);
|
||||||
|
color: var(--tf-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-rating-pill {
|
||||||
|
background: var(--tf-accent-soft);
|
||||||
|
color: var(--tf-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-status-completed,
|
||||||
|
.tf-status-draft {
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-status-review_requested,
|
||||||
|
.tf-status-sent,
|
||||||
|
.tf-status-viewed {
|
||||||
|
background: rgba(249, 115, 22, 0.12);
|
||||||
|
color: var(--tf-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-status-proof_ready,
|
||||||
|
.tf-status-published,
|
||||||
|
.tf-status-responded {
|
||||||
|
background: rgba(21, 128, 61, 0.12);
|
||||||
|
color: var(--tf-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-status-hidden {
|
||||||
|
background: rgba(100, 116, 139, 0.12);
|
||||||
|
color: var(--tf-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-inline-link {
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-inline-link:hover,
|
||||||
|
.tf-inline-link:focus {
|
||||||
|
color: var(--tf-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-activity-row-soft {
|
||||||
|
background: rgba(248, 250, 252, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-activity-link,
|
||||||
|
.tf-proof-mini {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-mini {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-table thead th {
|
||||||
|
border-bottom-width: 0;
|
||||||
|
color: var(--tf-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-table tbody td {
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
border-color: rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-detail-box {
|
||||||
|
background: rgba(248, 250, 252, 0.92);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-detail-box span {
|
||||||
|
display: block;
|
||||||
|
color: var(--tf-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-detail-box strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-check-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.9rem;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-check-row span {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(15, 118, 110, 0.12);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-check-field {
|
||||||
|
background: rgba(248, 250, 252, 0.92);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-sticky-panel {
|
||||||
|
top: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-feedback-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-feedback-option {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 96px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(248, 250, 252, 0.92);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-feedback-option input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-feedback-option:has(input:checked) {
|
||||||
|
border-color: rgba(15, 118, 110, 0.55);
|
||||||
|
background: rgba(236, 253, 245, 0.95);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
box-shadow: 0 12px 28px rgba(15, 118, 110, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-testimonial {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
border-left: 4px solid rgba(15, 118, 110, 0.22);
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-panel-centered {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-footer {
|
||||||
|
border-top: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-footer-brand {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
min-height: 50px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border-color: rgba(15, 23, 42, 0.10);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
.form-check-input:focus,
|
||||||
|
.btn:focus {
|
||||||
|
border-color: rgba(15, 118, 110, 0.4);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.tf-display {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-feedback-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.tf-device-card,
|
||||||
|
.tf-panel,
|
||||||
|
.tf-proof-card,
|
||||||
|
.tf-empty-state {
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-media-grid,
|
||||||
|
.tf-proof-media-grid-large {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-slot,
|
||||||
|
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tf-user-menu {
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
box-shadow: var(--tf-shadow-soft);
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-user-menu:hover,
|
||||||
|
.tf-user-menu:focus {
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-user-dropdown {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
box-shadow: var(--tf-shadow-soft);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-user-dropdown .dropdown-item {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-user-dropdown .dropdown-item:hover,
|
||||||
|
.tf-user-dropdown .dropdown-item:focus,
|
||||||
|
.tf-logout-link:hover,
|
||||||
|
.tf-logout-link:focus {
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-logout-link {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-auth-card {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-auth-points {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.tf-user-menu {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tf-workspace-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 118, 110, 0.12);
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.16);
|
||||||
|
color: #0f3f3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-chip strong,
|
||||||
|
.tf-dropdown-label strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-chip small,
|
||||||
|
.tf-dropdown-label span {
|
||||||
|
display: block;
|
||||||
|
color: rgba(30, 41, 59, 0.72);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-chip-mark {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #0f766e, #14b8a6);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-dropdown-label,
|
||||||
|
.tf-dropdown-section {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-switch {
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-switch small {
|
||||||
|
display: block;
|
||||||
|
color: rgba(30, 41, 59, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-switch.active {
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-inline-stat {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-inline-stat span {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(30, 41, 59, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-inline-stat strong {
|
||||||
|
display: block;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-role-card,
|
||||||
|
.tf-team-member {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-role-card {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-role-card span {
|
||||||
|
color: rgba(30, 41, 59, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-team-member-active {
|
||||||
|
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-role-pill {
|
||||||
|
background: rgba(249, 115, 22, 0.14);
|
||||||
|
color: #9a3412;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,21 +1,840 @@
|
|||||||
|
/* TrustForge design system */
|
||||||
:root {
|
:root {
|
||||||
--bg-color-start: #6a11cb;
|
--tf-bg: #f4f7f6;
|
||||||
--bg-color-end: #2575fc;
|
--tf-surface: rgba(255, 255, 255, 0.82);
|
||||||
--text-color: #ffffff;
|
--tf-surface-strong: #ffffff;
|
||||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
--tf-surface-dark: #0f172a;
|
||||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
--tf-border: rgba(15, 23, 42, 0.08);
|
||||||
|
--tf-primary: #0f766e;
|
||||||
|
--tf-primary-deep: #115e59;
|
||||||
|
--tf-secondary: #1e293b;
|
||||||
|
--tf-accent: #f97316;
|
||||||
|
--tf-accent-soft: #fff1e8;
|
||||||
|
--tf-success: #15803d;
|
||||||
|
--tf-text: #0f172a;
|
||||||
|
--tf-muted: #64748b;
|
||||||
|
--tf-shadow: 0 20px 60px rgba(15, 23, 42, 0.10);
|
||||||
|
--tf-shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||||
|
--tf-radius-xl: 28px;
|
||||||
|
--tf-radius-lg: 22px;
|
||||||
|
--tf-radius-md: 16px;
|
||||||
|
--tf-spacing: 1.5rem;
|
||||||
}
|
}
|
||||||
body {
|
|
||||||
margin: 0;
|
html {
|
||||||
font-family: 'Inter', sans-serif;
|
scroll-behavior: smooth;
|
||||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
}
|
||||||
color: var(--text-color);
|
|
||||||
display: flex;
|
body.trustforge-body {
|
||||||
justify-content: center;
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
align-items: center;
|
color: var(--tf-text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 35%),
|
||||||
|
radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 28%),
|
||||||
|
linear-gradient(180deg, #fbfcfb 0%, var(--tf-bg) 100%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, .navbar-brand, .tf-display, .tf-section-title, .tf-page-title {
|
||||||
|
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-background-glow {
|
||||||
|
position: fixed;
|
||||||
|
border-radius: 999px;
|
||||||
|
filter: blur(60px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-background-glow-1 {
|
||||||
|
width: 320px;
|
||||||
|
height: 320px;
|
||||||
|
background: rgba(15, 118, 110, 0.12);
|
||||||
|
top: 10%;
|
||||||
|
left: -4%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-background-glow-2 {
|
||||||
|
width: 360px;
|
||||||
|
height: 360px;
|
||||||
|
background: rgba(249, 115, 22, 0.10);
|
||||||
|
right: -6%;
|
||||||
|
top: 12%;
|
||||||
|
}
|
||||||
|
|
||||||
|
main, .tf-site-header, .tf-footer {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.py-lg-6 {
|
||||||
|
padding-top: 5rem !important;
|
||||||
|
padding-bottom: 5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-navbar {
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.68);
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-brand-mark {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--tf-primary), #2dd4bf);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-nav-toggle {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-navbar .nav-link {
|
||||||
|
color: var(--tf-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.6rem 0.95rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-navbar .nav-link:hover,
|
||||||
|
.tf-navbar .nav-link:focus {
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.85rem 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 0;
|
||||||
|
box-shadow: var(--tf-shadow-soft);
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn:hover,
|
||||||
|
.tf-btn:focus {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--tf-primary), #14b8a6);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn-primary:hover,
|
||||||
|
.tf-btn-primary:focus {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-btn-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-alert {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.12);
|
||||||
|
background: rgba(236, 253, 245, 0.88);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
box-shadow: var(--tf-shadow-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-hero-section {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
padding: 0.5rem 0.95rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-eyebrow-light {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-display {
|
||||||
|
font-size: clamp(2.75rem, 7vw, 5.2rem);
|
||||||
|
line-height: 0.98;
|
||||||
|
max-width: 13ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-lead,
|
||||||
|
.tf-page-subtitle {
|
||||||
|
color: var(--tf-muted);
|
||||||
|
font-size: 1.08rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
max-width: 62ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-page-title {
|
||||||
|
font-size: clamp(2rem, 4vw, 3.3rem);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-section-title {
|
||||||
|
font-size: clamp(1.75rem, 3.2vw, 2.6rem);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-stat-row span,
|
||||||
|
.tf-metric-card span {
|
||||||
|
display: block;
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-stat-chip,
|
||||||
|
.tf-metric-card {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: var(--tf-radius-md);
|
||||||
|
padding: 1.1rem 1rem;
|
||||||
|
box-shadow: var(--tf-shadow-soft);
|
||||||
|
color: var(--tf-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-card,
|
||||||
|
.tf-panel,
|
||||||
|
.tf-proof-card,
|
||||||
|
.tf-empty-state {
|
||||||
|
background: var(--tf-surface);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.75);
|
||||||
|
border-radius: var(--tf-radius-xl);
|
||||||
|
box-shadow: var(--tf-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
background: linear-gradient(135deg, rgba(249, 115, 22, 0.18), transparent);
|
||||||
|
border-radius: 36px;
|
||||||
|
right: -30px;
|
||||||
|
bottom: -40px;
|
||||||
|
transform: rotate(18deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-header span {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-device-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-mini-step,
|
||||||
|
.tf-activity-row,
|
||||||
|
.tf-proof-mini {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-mini-step.active {
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-preview,
|
||||||
|
.tf-panel {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-panel-dark {
|
||||||
|
background: linear-gradient(135deg, #0f172a, #1e293b);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-panel-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 18px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(15, 118, 110, 0.10);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-media-grid,
|
||||||
|
.tf-proof-media-grid-large,
|
||||||
|
.tf-proof-media-grid-static {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-media-grid-static {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-slot {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 170px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0)),
|
||||||
|
linear-gradient(135deg, rgba(15, 23, 42, 0.88), rgba(15, 118, 110, 0.82));
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: start;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-slot::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0.56));
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-slot-after {
|
||||||
|
background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-slot-has-media {
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-image {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-label {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 23, 42, 0.42);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-empty-proof {
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(248, 250, 252, 0.72);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-card-link {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-card-body {
|
||||||
|
padding: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-card-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-badge-verified,
|
||||||
|
.tf-rating-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.42rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-badge-verified {
|
||||||
|
background: rgba(21, 128, 61, 0.10);
|
||||||
|
color: var(--tf-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-rating-pill {
|
||||||
|
background: var(--tf-accent-soft);
|
||||||
|
color: var(--tf-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-status-completed,
|
||||||
|
.tf-status-draft {
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-status-review_requested,
|
||||||
|
.tf-status-sent,
|
||||||
|
.tf-status-viewed {
|
||||||
|
background: rgba(249, 115, 22, 0.12);
|
||||||
|
color: var(--tf-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-status-proof_ready,
|
||||||
|
.tf-status-published,
|
||||||
|
.tf-status-responded {
|
||||||
|
background: rgba(21, 128, 61, 0.12);
|
||||||
|
color: var(--tf-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-status-hidden {
|
||||||
|
background: rgba(100, 116, 139, 0.12);
|
||||||
|
color: var(--tf-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-inline-link {
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-inline-link:hover,
|
||||||
|
.tf-inline-link:focus {
|
||||||
|
color: var(--tf-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-activity-row-soft {
|
||||||
|
background: rgba(248, 250, 252, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-activity-link,
|
||||||
|
.tf-proof-mini {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-mini {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-table thead th {
|
||||||
|
border-bottom-width: 0;
|
||||||
|
color: var(--tf-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-table tbody td {
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
border-color: rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-detail-box {
|
||||||
|
background: rgba(248, 250, 252, 0.92);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-detail-box span {
|
||||||
|
display: block;
|
||||||
|
color: var(--tf-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-detail-box strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-check-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.9rem;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-check-row span {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(15, 118, 110, 0.12);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-check-field {
|
||||||
|
background: rgba(248, 250, 252, 0.92);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-sticky-panel {
|
||||||
|
top: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-feedback-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-feedback-option {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 96px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(248, 250, 252, 0.92);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-feedback-option input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-feedback-option:has(input:checked) {
|
||||||
|
border-color: rgba(15, 118, 110, 0.55);
|
||||||
|
background: rgba(236, 253, 245, 0.95);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
box-shadow: 0 12px 28px rgba(15, 118, 110, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-testimonial {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
border-left: 4px solid rgba(15, 118, 110, 0.22);
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-panel-centered {
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-footer {
|
||||||
|
border-top: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-footer-brand {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
min-height: 50px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border-color: rgba(15, 23, 42, 0.10);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus,
|
||||||
|
.form-check-input:focus,
|
||||||
|
.btn:focus {
|
||||||
|
border-color: rgba(15, 118, 110, 0.4);
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.tf-display {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-feedback-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.tf-device-card,
|
||||||
|
.tf-panel,
|
||||||
|
.tf-proof-card,
|
||||||
|
.tf-empty-state {
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-proof-media-grid,
|
||||||
|
.tf-proof-media-grid-large {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-photo-slot,
|
||||||
|
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tf-user-menu {
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||||
|
background: rgba(255, 255, 255, 0.78);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.8rem 1rem;
|
||||||
|
box-shadow: var(--tf-shadow-soft);
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-user-menu:hover,
|
||||||
|
.tf-user-menu:focus {
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-user-dropdown {
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
box-shadow: var(--tf-shadow-soft);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-user-dropdown .dropdown-item {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tf-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-user-dropdown .dropdown-item:hover,
|
||||||
|
.tf-user-dropdown .dropdown-item:focus,
|
||||||
|
.tf-logout-link:hover,
|
||||||
|
.tf-logout-link:focus {
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
color: var(--tf-primary-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-logout-link {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-auth-card {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-auth-points {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991.98px) {
|
||||||
|
.tf-user-menu {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tf-workspace-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(15, 118, 110, 0.12);
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.16);
|
||||||
|
color: #0f3f3b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-chip strong,
|
||||||
|
.tf-dropdown-label strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-chip small,
|
||||||
|
.tf-dropdown-label span {
|
||||||
|
display: block;
|
||||||
|
color: rgba(30, 41, 59, 0.72);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-chip-mark {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #0f766e, #14b8a6);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-dropdown-label,
|
||||||
|
.tf-dropdown-section {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-switch {
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-switch small {
|
||||||
|
display: block;
|
||||||
|
color: rgba(30, 41, 59, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-workspace-switch.active {
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-inline-stat {
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(15, 118, 110, 0.08);
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-inline-stat span {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: rgba(30, 41, 59, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-inline-stat strong {
|
||||||
|
display: block;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-role-card,
|
||||||
|
.tf-team-member {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-role-card {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-role-card span {
|
||||||
|
color: rgba(30, 41, 59, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-team-member-active {
|
||||||
|
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tf-role-pill {
|
||||||
|
background: rgba(249, 115, 22, 0.14);
|
||||||
|
color: #9a3412;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user