Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -2,6 +2,12 @@
|
|||||||
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
|
||||||
@ -9,32 +15,38 @@ 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',
|
||||||
@ -53,6 +65,8 @@ 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'
|
||||||
@ -69,6 +83,7 @@ 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',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -77,6 +92,10 @@ 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',
|
||||||
@ -91,48 +110,73 @@ 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.MinimumLengthValidator'},
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
{'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_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend')
|
# Email
|
||||||
EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1')
|
EMAIL_BACKEND = os.getenv(
|
||||||
EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587'))
|
"EMAIL_BACKEND",
|
||||||
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
|
"django.core.mail.backends.smtp.EmailBackend"
|
||||||
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
|
)
|
||||||
EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
|
EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1")
|
||||||
EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() == 'true'
|
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
||||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@example.com')
|
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
|
||||||
|
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
|
||||||
|
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
||||||
|
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
|
||||||
|
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com")
|
||||||
CONTACT_EMAIL_TO = [
|
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
|
||||||
LOGIN_URL = 'login'
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
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,17 +1,29 @@
|
|||||||
"""
|
"""
|
||||||
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.conf import settings
|
|
||||||
from django.conf.urls.static import static
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
|
||||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,59 +1,3 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Business, BusinessMembership, Customer, Feedback, Job, JobMedia, ProofCard, ReviewRequest
|
# Register your models here.
|
||||||
|
|
||||||
|
|
||||||
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,33 +1,13 @@
|
|||||||
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 and active workspace context globally.
|
Adds project-specific environment variables to the template 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", ""),
|
||||||
'deployment_timestamp': int(time.time()),
|
# Used for cache-busting static assets
|
||||||
'current_membership': current_membership,
|
"deployment_timestamp": int(time.time()),
|
||||||
'user_memberships': memberships,
|
|
||||||
}
|
}
|
||||||
|
|||||||
372
core/forms.py
372
core/forms.py
@ -1,372 +0,0 @@
|
|||||||
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}),
|
|
||||||
}
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
# 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'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
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),
|
|
||||||
]
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
# 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')},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
220
core/models.py
220
core/models.py
@ -1,221 +1,3 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
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,133 +1,25 @@
|
|||||||
{% load static %}
|
<!DOCTYPE html>
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||||
<title>{% block title %}TrustForge{% endblock %}</title>
|
{% if 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 name="description" content="{{ project_description }}">
|
||||||
<meta name="author" content="TrustForge">
|
<meta property="og:description" content="{{ project_description }}">
|
||||||
<meta name="keywords" content="proof cards, service business reviews, trust marketing, contractor testimonials, local service SaaS">
|
<meta property="twitter:description" content="{{ project_description }}">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
{% endif %}
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
{% if 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="og: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">
|
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||||
|
{% 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>
|
|
||||||
|
|
||||||
<header class="tf-site-header sticky-top">
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg tf-navbar">
|
{% block content %}{% endblock %}
|
||||||
<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 %}
|
|
||||||
</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>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
<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,195 +1,145 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}TrustForge | Turn completed jobs into proof that wins the next customer{% endblock %}
|
{% block title %}{{ project_name }}{% 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 %}
|
||||||
<section class="tf-hero-section py-5 py-lg-6">
|
<main>
|
||||||
<div class="container py-lg-5">
|
<div class="card">
|
||||||
<div class="row align-items-center g-5">
|
<h1>Analyzing your requirements and generating your app…</h1>
|
||||||
<div class="col-lg-6">
|
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||||
<div class="tf-eyebrow mb-3">Trust engine for service businesses</div>
|
<span class="sr-only">Loading…</span>
|
||||||
<h1 class="tf-display mb-4">Every completed job becomes proof that closes the next one.</h1>
|
|
||||||
<p class="tf-lead mb-4">TrustForge gives contractors, roofers, HVAC teams, plumbers, electricians, junk removal crews, and landscapers a fast field workflow: finish the job, send the review request, generate a premium proof card, and publish conversion-ready assets.</p>
|
|
||||||
<div class="d-flex flex-wrap gap-3 mb-4">
|
|
||||||
<a class="btn tf-btn tf-btn-primary btn-lg" href="{% url 'job_create' %}">Start a completed job</a>
|
|
||||||
<a class="btn tf-btn tf-btn-secondary btn-lg" href="{% url 'dashboard' %}">View dashboard</a>
|
|
||||||
</div>
|
|
||||||
<div class="row g-3 tf-stat-row">
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="tf-stat-chip">
|
|
||||||
<span>{{ stats.completed_jobs|default:0 }}</span>
|
|
||||||
Jobs logged
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="tf-stat-chip">
|
|
||||||
<span>{{ stats.review_requests|default:0 }}</span>
|
|
||||||
Requests sent
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="tf-stat-chip">
|
|
||||||
<span>{{ stats.proof_cards|default:0 }}</span>
|
|
||||||
Proof cards
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-md-3">
|
|
||||||
<div class="tf-stat-chip">
|
|
||||||
<span>{{ business_count }}</span>
|
|
||||||
Active businesses
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-6">
|
|
||||||
<div class="tf-device-card">
|
|
||||||
<div class="tf-device-header">
|
|
||||||
<span></span><span></span><span></span>
|
|
||||||
</div>
|
|
||||||
<div class="tf-device-body">
|
|
||||||
<div class="tf-mini-step active">1. Job completed</div>
|
|
||||||
<div class="tf-mini-step active">2. Review requested</div>
|
|
||||||
<div class="tf-mini-step active">3. Proof created</div>
|
|
||||||
<div class="tf-proof-preview mt-4">
|
|
||||||
{% if featured_proofs %}
|
|
||||||
{% with proof=featured_proofs.0 %}
|
|
||||||
<div class="mb-3">
|
|
||||||
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
|
|
||||||
</div>
|
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
|
||||||
<div>
|
|
||||||
<div class="tf-card-tag">{{ proof.job.service_type }}</div>
|
|
||||||
<h2 class="tf-card-title h4 mt-2 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
|
|
||||||
<div class="text-secondary-emphasis small">Verified completion · {{ proof.job.completed_at|date:"M j, Y" }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="tf-rating-pill">{% if proof.rating %}★ {{ proof.rating }}.0{% else %}Verified{% endif %}</div>
|
|
||||||
</div>
|
|
||||||
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:120 }}”{% else %}Premium proof cards turn real field work into a conversion asset that belongs on your homepage and service pages.{% endif %}</p>
|
|
||||||
<div class="d-flex justify-content-between small text-secondary-emphasis">
|
|
||||||
<span>{{ proof.customer_display_name }}</span>
|
|
||||||
<span>{{ proof.verified_label }}</span>
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
{% else %}
|
|
||||||
<div class="tf-empty-proof">
|
|
||||||
<div class="tf-proof-media-grid mb-3">
|
|
||||||
<div class="tf-photo-slot">Before</div>
|
|
||||||
<div class="tf-photo-slot tf-photo-slot-after">After</div>
|
|
||||||
</div>
|
|
||||||
<h2 class="h4 mb-2">Your first proof card appears here</h2>
|
|
||||||
<p class="mb-0 text-secondary-emphasis">Log a completed job to instantly generate the draft card, review request, and proof pipeline.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
||||||
|
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
||||||
|
<p class="runtime">
|
||||||
|
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
||||||
|
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</main>
|
||||||
|
<footer>
|
||||||
<section class="py-5">
|
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||||
<div class="container">
|
</footer>
|
||||||
<div class="row g-4">
|
{% endblock %}
|
||||||
<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 %}
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
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
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
{% 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 +0,0 @@
|
|||||||
Reset your TrustForge password
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
{% 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,221 +1,3 @@
|
|||||||
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
|
|
||||||
|
|
||||||
from .models import Business, BusinessMembership, Customer, Job, JobMedia, ProofCard, ReviewRequest
|
# Create your tests here.
|
||||||
|
|
||||||
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,51 +1,7 @@
|
|||||||
from django.contrib.auth.views import LogoutView
|
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import (
|
from .views import home
|
||||||
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,729 +1,25 @@
|
|||||||
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.contrib import messages
|
from django.shortcuts import render
|
||||||
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 _theme_context() -> dict:
|
def home(request):
|
||||||
return {
|
"""Render the landing screen with loader and environment details."""
|
||||||
'project_name': 'TrustForge',
|
host_name = request.get_host().lower()
|
||||||
'project_description': (
|
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
||||||
'TrustForge turns completed service jobs into proof cards, testimonials, and conversion assets '
|
now = timezone.now()
|
||||||
'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 = {
|
||||||
**_theme_context(),
|
"project_name": "New Style",
|
||||||
'auth_page_title': 'Create your TrustForge account',
|
"agent_brand": agent_brand,
|
||||||
'auth_page_description': 'Start with secure account access, then connect your business workspace, team roles, and protected proof pipeline.',
|
"django_version": django_version(),
|
||||||
'form': form,
|
"python_version": platform.python_version(),
|
||||||
|
"current_time": now,
|
||||||
|
"host_name": host_name,
|
||||||
|
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||||
|
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||||
}
|
}
|
||||||
return render(request, 'registration/signup.html', context)
|
return render(request, "core/index.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,840 +1,4 @@
|
|||||||
/* TrustForge design system */
|
/* Custom styles for the application */
|
||||||
:root {
|
body {
|
||||||
--tf-bg: #f4f7f6;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
--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,840 +1,21 @@
|
|||||||
/* TrustForge design system */
|
|
||||||
:root {
|
:root {
|
||||||
--tf-bg: #f4f7f6;
|
--bg-color-start: #6a11cb;
|
||||||
--tf-surface: rgba(255, 255, 255, 0.82);
|
--bg-color-end: #2575fc;
|
||||||
--tf-surface-strong: #ffffff;
|
--text-color: #ffffff;
|
||||||
--tf-surface-dark: #0f172a;
|
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||||
--tf-border: rgba(15, 23, 42, 0.08);
|
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||||
--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 {
|
||||||
html {
|
margin: 0;
|
||||||
scroll-behavior: smooth;
|
font-family: 'Inter', sans-serif;
|
||||||
}
|
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||||
|
color: var(--text-color);
|
||||||
body.trustforge-body {
|
display: flex;
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
justify-content: center;
|
||||||
color: var(--tf-text);
|
align-items: center;
|
||||||
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;
|
||||||
position: relative;
|
text-align: center;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
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;
|
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