Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5809ee0af7 | ||
|
|
159e91248c |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -2,12 +2,6 @@
|
||||
Django settings for config project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.2.7.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@ -15,38 +9,32 @@ import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
load_dotenv(BASE_DIR.parent / ".env")
|
||||
load_dotenv(BASE_DIR.parent / '.env')
|
||||
|
||||
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me")
|
||||
DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true"
|
||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'change-me')
|
||||
DEBUG = os.getenv('DJANGO_DEBUG', 'true').lower() == 'true'
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
'127.0.0.1',
|
||||
'localhost',
|
||||
os.getenv('HOST_FQDN', ''),
|
||||
]
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
origin for origin in [
|
||||
os.getenv("HOST_FQDN", ""),
|
||||
os.getenv("CSRF_TRUSTED_ORIGIN", "")
|
||||
os.getenv('HOST_FQDN', ''),
|
||||
os.getenv('CSRF_TRUSTED_ORIGIN', ''),
|
||||
] if origin
|
||||
]
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
f"https://{host}" if not host.startswith(("http://", "https://")) else host
|
||||
f'https://{host}' if not host.startswith(('http://', 'https://')) else host
|
||||
for host in CSRF_TRUSTED_ORIGINS
|
||||
]
|
||||
|
||||
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SAMESITE = "None"
|
||||
CSRF_COOKIE_SAMESITE = "None"
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||
|
||||
# Application definition
|
||||
SESSION_COOKIE_SAMESITE = 'None'
|
||||
CSRF_COOKIE_SAMESITE = 'None'
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
@ -65,8 +53,6 @@ MIDDLEWARE = [
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
|
||||
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
X_FRAME_OPTIONS = 'ALLOWALL'
|
||||
@ -83,7 +69,6 @@ TEMPLATES = [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
# IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
|
||||
'core.context_processors.project_context',
|
||||
],
|
||||
},
|
||||
@ -92,10 +77,6 @@ TEMPLATES = [
|
||||
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.mysql',
|
||||
@ -110,73 +91,48 @@ DATABASES = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
BASE_DIR / 'assets',
|
||||
BASE_DIR / 'node_modules',
|
||||
]
|
||||
MEDIA_URL = 'media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Email
|
||||
EMAIL_BACKEND = os.getenv(
|
||||
"EMAIL_BACKEND",
|
||||
"django.core.mail.backends.smtp.EmailBackend"
|
||||
)
|
||||
EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1")
|
||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
|
||||
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "")
|
||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "")
|
||||
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
|
||||
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
|
||||
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com")
|
||||
EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend')
|
||||
EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1')
|
||||
EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587'))
|
||||
EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '')
|
||||
EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '')
|
||||
EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true'
|
||||
EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() == 'true'
|
||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@example.com')
|
||||
CONTACT_EMAIL_TO = [
|
||||
item.strip()
|
||||
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
|
||||
for item in os.getenv('CONTACT_EMAIL_TO', DEFAULT_FROM_EMAIL).split(',')
|
||||
if item.strip()
|
||||
]
|
||||
|
||||
# When both TLS and SSL flags are enabled, prefer SSL explicitly
|
||||
if EMAIL_USE_SSL:
|
||||
EMAIL_USE_TLS = False
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
LOGIN_URL = 'login'
|
||||
LOGIN_REDIRECT_URL = 'dashboard'
|
||||
LOGOUT_REDIRECT_URL = 'home'
|
||||
PASSWORD_RESET_TIMEOUT = 60 * 60 * 24
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
@ -1,29 +1,17 @@
|
||||
"""
|
||||
URL configuration for config project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("", include("core.urls")),
|
||||
path('admin/', admin.site.urls),
|
||||
path('', include('core.urls')),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
||||
urlpatterns += static('/assets/', document_root=settings.BASE_DIR / 'assets')
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
BIN
core/__pycache__/tests.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,3 +1,59 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
from .models import Business, BusinessMembership, Customer, Feedback, Job, JobMedia, ProofCard, ReviewRequest
|
||||
|
||||
|
||||
class JobMediaInline(admin.TabularInline):
|
||||
model = JobMedia
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(Business)
|
||||
class BusinessAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'industry', 'primary_city', 'primary_state', 'is_active')
|
||||
list_filter = ('industry', 'is_active')
|
||||
search_fields = ('name', 'slug', 'primary_city')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
|
||||
|
||||
@admin.register(BusinessMembership)
|
||||
class BusinessMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'business', 'role', 'created_at')
|
||||
list_filter = ('role', 'business')
|
||||
search_fields = ('user__email', 'user__first_name', 'user__last_name', 'business__name')
|
||||
|
||||
|
||||
@admin.register(Customer)
|
||||
class CustomerAdmin(admin.ModelAdmin):
|
||||
list_display = ('full_name', 'business', 'city', 'state', 'email', 'phone')
|
||||
list_filter = ('business', 'state')
|
||||
search_fields = ('full_name', 'email', 'phone')
|
||||
|
||||
|
||||
@admin.register(Job)
|
||||
class JobAdmin(admin.ModelAdmin):
|
||||
list_display = ('service_type', 'business', 'customer', 'city', 'state', 'completed_at', 'status')
|
||||
list_filter = ('business', 'status', 'state', 'completed_at')
|
||||
search_fields = ('service_type', 'customer__full_name', 'technician_name')
|
||||
inlines = [JobMediaInline]
|
||||
|
||||
|
||||
@admin.register(ReviewRequest)
|
||||
class ReviewRequestAdmin(admin.ModelAdmin):
|
||||
list_display = ('job', 'channel', 'status', 'sent_at', 'reviewed_at')
|
||||
list_filter = ('channel', 'status')
|
||||
search_fields = ('job__customer__full_name', 'job__service_type')
|
||||
|
||||
|
||||
@admin.register(Feedback)
|
||||
class FeedbackAdmin(admin.ModelAdmin):
|
||||
list_display = ('review_request', 'experience', 'rating', 'follow_up_required', 'is_public_approved', 'created_at')
|
||||
list_filter = ('experience', 'follow_up_required', 'is_public_approved')
|
||||
search_fields = ('review_request__job__customer__full_name', 'testimonial')
|
||||
|
||||
|
||||
@admin.register(ProofCard)
|
||||
class ProofCardAdmin(admin.ModelAdmin):
|
||||
list_display = ('job', 'customer_display_name', 'status', 'is_featured', 'rating', 'published_at')
|
||||
list_filter = ('status', 'is_featured')
|
||||
search_fields = ('customer_display_name', 'job__service_type', 'testimonial_quote')
|
||||
|
||||
@ -1,13 +1,33 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
from .models import BusinessMembership
|
||||
|
||||
ACTIVE_BUSINESS_SESSION_KEY = 'trustforge_active_business_id'
|
||||
|
||||
|
||||
def project_context(request):
|
||||
"""
|
||||
Adds project-specific environment variables to the template context globally.
|
||||
Adds project-specific environment variables and active workspace context globally.
|
||||
"""
|
||||
current_membership = None
|
||||
memberships = []
|
||||
if getattr(request, 'user', None) and request.user.is_authenticated:
|
||||
memberships = list(
|
||||
BusinessMembership.objects.select_related('business').filter(
|
||||
user=request.user,
|
||||
business__is_active=True,
|
||||
)
|
||||
)
|
||||
active_business_id = request.session.get(ACTIVE_BUSINESS_SESSION_KEY)
|
||||
current_membership = next((item for item in memberships if item.business_id == active_business_id), None)
|
||||
if current_membership is None and memberships:
|
||||
current_membership = memberships[0]
|
||||
|
||||
return {
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
# Used for cache-busting static assets
|
||||
"deployment_timestamp": int(time.time()),
|
||||
'project_description': os.getenv('PROJECT_DESCRIPTION', ''),
|
||||
'project_image_url': os.getenv('PROJECT_IMAGE_URL', ''),
|
||||
'deployment_timestamp': int(time.time()),
|
||||
'current_membership': current_membership,
|
||||
'user_memberships': memberships,
|
||||
}
|
||||
|
||||
372
core/forms.py
Normal file
372
core/forms.py
Normal file
@ -0,0 +1,372 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth import authenticate, get_user_model
|
||||
from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, SetPasswordForm, UserCreationForm
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Business, BusinessMembership, Feedback, ProofCard, ReviewRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TrustForgeAuthenticationForm(AuthenticationForm):
|
||||
username = forms.CharField(
|
||||
label='Work email',
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'class': 'form-control form-control-lg',
|
||||
'placeholder': 'you@company.com',
|
||||
'autocomplete': 'email',
|
||||
}
|
||||
),
|
||||
)
|
||||
password = forms.CharField(
|
||||
label='Password',
|
||||
strip=False,
|
||||
widget=forms.PasswordInput(
|
||||
attrs={
|
||||
'class': 'form-control form-control-lg',
|
||||
'placeholder': 'Enter your password',
|
||||
'autocomplete': 'current-password',
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
email = (self.cleaned_data.get('username') or '').strip().lower()
|
||||
password = self.cleaned_data.get('password')
|
||||
|
||||
if email and password:
|
||||
matched_user = User._default_manager.filter(email__iexact=email).first()
|
||||
auth_username = matched_user.get_username() if matched_user else email
|
||||
self.user_cache = authenticate(self.request, username=auth_username, password=password)
|
||||
if self.user_cache is None:
|
||||
raise self.get_invalid_login_error()
|
||||
self.confirm_login_allowed(self.user_cache)
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class SignUpForm(UserCreationForm):
|
||||
first_name = forms.CharField(
|
||||
required=False,
|
||||
max_length=150,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Avery'}),
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
required=False,
|
||||
max_length=150,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Stone'}),
|
||||
)
|
||||
email = forms.EmailField(
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'class': 'form-control form-control-lg',
|
||||
'placeholder': 'owner@servicebrand.com',
|
||||
'autocomplete': 'email',
|
||||
}
|
||||
)
|
||||
)
|
||||
password1 = forms.CharField(
|
||||
label='Password',
|
||||
strip=False,
|
||||
widget=forms.PasswordInput(
|
||||
attrs={
|
||||
'class': 'form-control form-control-lg',
|
||||
'placeholder': 'Create a password',
|
||||
'autocomplete': 'new-password',
|
||||
}
|
||||
),
|
||||
)
|
||||
password2 = forms.CharField(
|
||||
label='Confirm password',
|
||||
strip=False,
|
||||
widget=forms.PasswordInput(
|
||||
attrs={
|
||||
'class': 'form-control form-control-lg',
|
||||
'placeholder': 'Repeat your password',
|
||||
'autocomplete': 'new-password',
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
class Meta(UserCreationForm.Meta):
|
||||
model = User
|
||||
fields = ('first_name', 'last_name', 'email', 'password1', 'password2')
|
||||
|
||||
def clean_email(self):
|
||||
email = (self.cleaned_data.get('email') or '').strip().lower()
|
||||
if User._default_manager.filter(email__iexact=email).exists() or User._default_manager.filter(username__iexact=email).exists():
|
||||
raise forms.ValidationError('An account with this email already exists.')
|
||||
return email
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
email = self.cleaned_data['email']
|
||||
user.username = email
|
||||
user.email = email
|
||||
user.first_name = self.cleaned_data.get('first_name', '').strip()
|
||||
user.last_name = self.cleaned_data.get('last_name', '').strip()
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class ProfileSettingsForm(forms.ModelForm):
|
||||
first_name = forms.CharField(
|
||||
required=False,
|
||||
max_length=150,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Avery'}),
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
required=False,
|
||||
max_length=150,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Stone'}),
|
||||
)
|
||||
email = forms.EmailField(
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'class': 'form-control form-control-lg',
|
||||
'placeholder': 'you@company.com',
|
||||
'autocomplete': 'email',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('first_name', 'last_name', 'email')
|
||||
|
||||
def clean_email(self):
|
||||
email = (self.cleaned_data.get('email') or '').strip().lower()
|
||||
email_exists = User._default_manager.filter(email__iexact=email).exclude(pk=self.instance.pk).exists()
|
||||
username_exists = User._default_manager.filter(username__iexact=email).exclude(pk=self.instance.pk).exists()
|
||||
if email_exists or username_exists:
|
||||
raise forms.ValidationError('Another account already uses this email.')
|
||||
return email
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
email = self.cleaned_data['email']
|
||||
user.email = email
|
||||
user.username = email
|
||||
user.first_name = self.cleaned_data.get('first_name', '').strip()
|
||||
user.last_name = self.cleaned_data.get('last_name', '').strip()
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class TrustForgePasswordResetForm(PasswordResetForm):
|
||||
email = forms.EmailField(
|
||||
widget=forms.EmailInput(
|
||||
attrs={
|
||||
'class': 'form-control form-control-lg',
|
||||
'placeholder': 'you@company.com',
|
||||
'autocomplete': 'email',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def send_mail(self, *args, **kwargs):
|
||||
try:
|
||||
return super().send_mail(*args, **kwargs)
|
||||
except Exception:
|
||||
logger.exception('Password reset email failed to send.')
|
||||
|
||||
|
||||
class TrustForgeSetPasswordForm(SetPasswordForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['new_password1'].widget.attrs.update(
|
||||
{
|
||||
'class': 'form-control form-control-lg',
|
||||
'placeholder': 'Create a new password',
|
||||
'autocomplete': 'new-password',
|
||||
}
|
||||
)
|
||||
self.fields['new_password2'].widget.attrs.update(
|
||||
{
|
||||
'class': 'form-control form-control-lg',
|
||||
'placeholder': 'Confirm the new password',
|
||||
'autocomplete': 'new-password',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BusinessOnboardingForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Business
|
||||
fields = ('name', 'industry', 'primary_city', 'primary_state', 'google_review_url')
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Summit Home Services'}),
|
||||
'industry': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'HVAC, Roofing, Plumbing, Junk Removal…'}),
|
||||
'primary_city': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Austin'}),
|
||||
'primary_state': forms.TextInput(attrs={'class': 'form-control text-uppercase', 'placeholder': 'TX'}),
|
||||
'google_review_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://g.page/r/.../review'}),
|
||||
}
|
||||
|
||||
def clean_primary_state(self):
|
||||
return (self.cleaned_data.get('primary_state') or '').upper()
|
||||
|
||||
|
||||
class BusinessSettingsForm(BusinessOnboardingForm):
|
||||
pass
|
||||
|
||||
|
||||
class TeamMemberInviteForm(forms.Form):
|
||||
first_name = forms.CharField(
|
||||
required=False,
|
||||
max_length=150,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Jamie'}),
|
||||
)
|
||||
last_name = forms.CharField(
|
||||
required=False,
|
||||
max_length=150,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Rivera'}),
|
||||
)
|
||||
email = forms.EmailField(
|
||||
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tech@servicebrand.com'})
|
||||
)
|
||||
role = forms.ChoiceField(
|
||||
choices=BusinessMembership.Role.choices,
|
||||
initial=BusinessMembership.Role.TECHNICIAN,
|
||||
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||
)
|
||||
|
||||
def clean_email(self):
|
||||
return (self.cleaned_data.get('email') or '').strip().lower()
|
||||
|
||||
|
||||
class JobIntakeForm(forms.Form):
|
||||
business = forms.ModelChoiceField(
|
||||
queryset=Business.objects.filter(is_active=True),
|
||||
empty_label=None,
|
||||
widget=forms.Select(attrs={'class': 'form-select form-control-lg'}),
|
||||
)
|
||||
customer_name = forms.CharField(
|
||||
max_length=160,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Homeowner or business contact'}),
|
||||
)
|
||||
customer_email = forms.EmailField(
|
||||
required=False,
|
||||
widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'customer@example.com'}),
|
||||
)
|
||||
customer_phone = forms.CharField(
|
||||
required=False,
|
||||
max_length=40,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '(555) 555-0123'}),
|
||||
)
|
||||
service_type = forms.CharField(
|
||||
max_length=120,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Roof replacement, HVAC tune-up, junk removal…'}),
|
||||
)
|
||||
description = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'What did the crew complete on-site?'}),
|
||||
)
|
||||
customer_city = forms.CharField(
|
||||
max_length=120,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Austin'}),
|
||||
)
|
||||
customer_state = forms.CharField(
|
||||
max_length=2,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control text-uppercase', 'placeholder': 'TX'}),
|
||||
)
|
||||
technician_name = forms.CharField(
|
||||
required=False,
|
||||
max_length=120,
|
||||
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Luis R.'}),
|
||||
)
|
||||
completion_date = forms.DateField(
|
||||
initial=timezone.localdate,
|
||||
widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
|
||||
)
|
||||
project_value = forms.DecimalField(
|
||||
required=False,
|
||||
min_value=0,
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '4500'}),
|
||||
)
|
||||
before_photo = forms.FileField(
|
||||
required=False,
|
||||
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'}),
|
||||
)
|
||||
after_photo = forms.FileField(
|
||||
required=False,
|
||||
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'}),
|
||||
)
|
||||
anonymize_customer = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True,
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
)
|
||||
send_review_request = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True,
|
||||
widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
)
|
||||
review_channel = forms.ChoiceField(
|
||||
choices=ReviewRequest.Channel.choices,
|
||||
initial=ReviewRequest.Channel.EMAIL,
|
||||
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||
)
|
||||
|
||||
def __init__(self, *args, business: Business | None = None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if business is not None:
|
||||
self.fields['business'].queryset = Business.objects.filter(pk=business.pk)
|
||||
self.fields['business'].initial = business
|
||||
self.fields['business'].widget = forms.HiddenInput()
|
||||
|
||||
def clean_customer_state(self):
|
||||
return self.cleaned_data['customer_state'].upper()
|
||||
|
||||
|
||||
class PublicFeedbackForm(forms.Form):
|
||||
experience = forms.ChoiceField(
|
||||
choices=Feedback.Experience.choices,
|
||||
widget=forms.RadioSelect,
|
||||
)
|
||||
testimonial = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'Tell us what stood out about the service…'}),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
experience = cleaned_data.get('experience')
|
||||
testimonial = (cleaned_data.get('testimonial') or '').strip()
|
||||
if experience in {Feedback.Experience.GREAT, Feedback.Experience.GOOD} and len(testimonial) < 12:
|
||||
self.add_error('testimonial', 'For positive feedback, add a short testimonial so it can power the proof card.')
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class ProofCardForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ProofCard
|
||||
fields = [
|
||||
'customer_display_name',
|
||||
'is_anonymized',
|
||||
'testimonial_quote',
|
||||
'rating',
|
||||
'status',
|
||||
'is_featured',
|
||||
'attached_widget_label',
|
||||
'attached_pages',
|
||||
]
|
||||
widgets = {
|
||||
'customer_display_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'is_anonymized': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'testimonial_quote': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
||||
'rating': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 5}),
|
||||
'status': forms.Select(attrs={'class': 'form-select'}),
|
||||
'is_featured': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
|
||||
'attached_widget_label': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'attached_pages': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
143
core/migrations/0001_initial.py
Normal file
143
core/migrations/0001_initial.py
Normal file
@ -0,0 +1,143 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-11 01:16
|
||||
|
||||
import core.models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Business',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=160)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('industry', models.CharField(default='Home Services', max_length=120)),
|
||||
('primary_city', models.CharField(max_length=120)),
|
||||
('primary_state', models.CharField(max_length=2)),
|
||||
('google_review_url', models.URLField(blank=True)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'businesses',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Customer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('full_name', models.CharField(max_length=160)),
|
||||
('email', models.EmailField(blank=True, max_length=254)),
|
||||
('phone', models.CharField(blank=True, max_length=40)),
|
||||
('city', models.CharField(max_length=120)),
|
||||
('state', models.CharField(max_length=2)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='core.business')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['full_name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Job',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('service_type', models.CharField(max_length=120)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('technician_name', models.CharField(blank=True, max_length=120)),
|
||||
('city', models.CharField(max_length=120)),
|
||||
('state', models.CharField(max_length=2)),
|
||||
('completed_at', models.DateField(default=django.utils.timezone.localdate)),
|
||||
('project_value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
|
||||
('status', models.CharField(choices=[('completed', 'Completed'), ('review_requested', 'Review requested'), ('proof_ready', 'Proof ready')], default='completed', max_length=32)),
|
||||
('is_verified', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='core.business')),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='core.customer')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-completed_at', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='JobMedia',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('media_type', models.CharField(choices=[('before', 'Before'), ('after', 'After')], max_length=16)),
|
||||
('file', models.FileField(blank=True, upload_to=core.models.job_media_upload_path)),
|
||||
('caption', models.CharField(blank=True, max_length=140)),
|
||||
('display_order', models.PositiveSmallIntegerField(default=0)),
|
||||
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media', to='core.job')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'job media',
|
||||
'ordering': ['display_order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProofCard',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('customer_display_name', models.CharField(max_length=160)),
|
||||
('is_anonymized', models.BooleanField(default=False)),
|
||||
('testimonial_quote', models.TextField(blank=True)),
|
||||
('rating', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('hidden', 'Hidden')], default='draft', max_length=16)),
|
||||
('is_featured', models.BooleanField(default=False)),
|
||||
('attached_widget_label', models.CharField(blank=True, max_length=120)),
|
||||
('attached_pages', models.CharField(blank=True, max_length=200)),
|
||||
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='proof_card', to='core.job')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-is_featured', '-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ReviewRequest',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('status', models.CharField(choices=[('sent', 'Sent'), ('viewed', 'Viewed'), ('responded', 'Responded')], default='sent', max_length=20)),
|
||||
('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('manual', 'Manual share')], default='email', max_length=16)),
|
||||
('sent_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('last_opened_at', models.DateTimeField(blank=True, null=True)),
|
||||
('reviewed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('delivery_note', models.CharField(blank=True, max_length=200)),
|
||||
('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review_request', to='core.job')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-sent_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Feedback',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('experience', models.CharField(choices=[('great', 'Great'), ('good', 'Good'), ('okay', 'Okay'), ('bad', 'Bad')], max_length=12)),
|
||||
('rating', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('testimonial', models.TextField(blank=True)),
|
||||
('follow_up_required', models.BooleanField(default=False)),
|
||||
('is_public_approved', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('review_request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='core.reviewrequest')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
98
core/migrations/0002_seed_trustforge_demo.py
Normal file
98
core/migrations/0002_seed_trustforge_demo.py
Normal file
@ -0,0 +1,98 @@
|
||||
from django.db import migrations
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def seed_trustforge_demo(apps, schema_editor):
|
||||
Business = apps.get_model('core', 'Business')
|
||||
Customer = apps.get_model('core', 'Customer')
|
||||
Job = apps.get_model('core', 'Job')
|
||||
ProofCard = apps.get_model('core', 'ProofCard')
|
||||
ReviewRequest = apps.get_model('core', 'ReviewRequest')
|
||||
Feedback = apps.get_model('core', 'Feedback')
|
||||
|
||||
business, _ = Business.objects.get_or_create(
|
||||
slug='summit-home-services',
|
||||
defaults={
|
||||
'name': 'Summit Home Services',
|
||||
'industry': 'Roofing & Exterior',
|
||||
'primary_city': 'Austin',
|
||||
'primary_state': 'TX',
|
||||
'google_review_url': 'https://example.com/google-review',
|
||||
'is_active': True,
|
||||
},
|
||||
)
|
||||
customer, _ = Customer.objects.get_or_create(
|
||||
business=business,
|
||||
full_name='Avery Johnson',
|
||||
defaults={
|
||||
'email': 'avery@example.com',
|
||||
'phone': '(512) 555-0148',
|
||||
'city': 'Austin',
|
||||
'state': 'TX',
|
||||
},
|
||||
)
|
||||
job, _ = Job.objects.get_or_create(
|
||||
business=business,
|
||||
customer=customer,
|
||||
service_type='Roof replacement',
|
||||
defaults={
|
||||
'description': 'Replaced weather-damaged shingles, sealed flashing, and completed a same-day cleanup.',
|
||||
'technician_name': 'Luis Ramirez',
|
||||
'city': 'Austin',
|
||||
'state': 'TX',
|
||||
'completed_at': timezone.localdate(),
|
||||
'project_value': '14250.00',
|
||||
'status': 'proof_ready',
|
||||
'is_verified': True,
|
||||
},
|
||||
)
|
||||
proof_card, _ = ProofCard.objects.get_or_create(
|
||||
job=job,
|
||||
defaults={
|
||||
'customer_display_name': 'Verified homeowner',
|
||||
'is_anonymized': True,
|
||||
'testimonial_quote': 'The crew communicated clearly, protected the landscaping, and finished the roof beautifully in one day.',
|
||||
'rating': 5,
|
||||
'status': 'published',
|
||||
'is_featured': True,
|
||||
'attached_widget_label': 'Homepage proof gallery',
|
||||
'attached_pages': 'Homepage, Roofing service page',
|
||||
'published_at': timezone.now(),
|
||||
},
|
||||
)
|
||||
review_request, _ = ReviewRequest.objects.get_or_create(
|
||||
job=job,
|
||||
defaults={
|
||||
'status': 'responded',
|
||||
'channel': 'email',
|
||||
'sent_at': timezone.now(),
|
||||
'reviewed_at': timezone.now(),
|
||||
'delivery_note': 'Seeded demo review request',
|
||||
},
|
||||
)
|
||||
Feedback.objects.get_or_create(
|
||||
review_request=review_request,
|
||||
defaults={
|
||||
'experience': 'great',
|
||||
'rating': 5,
|
||||
'testimonial': proof_card.testimonial_quote,
|
||||
'follow_up_required': False,
|
||||
'is_public_approved': True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def remove_trustforge_demo(apps, schema_editor):
|
||||
Business = apps.get_model('core', 'Business')
|
||||
Business.objects.filter(slug='summit-home-services').delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_trustforge_demo, remove_trustforge_demo),
|
||||
]
|
||||
31
core/migrations/0003_businessmembership.py
Normal file
31
core/migrations/0003_businessmembership.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.7 on 2026-04-11 01:43
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_seed_trustforge_demo'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BusinessMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('manager', 'Manager'), ('technician', 'Technician')], default='owner', max_length=24)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='core.business')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_memberships', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['business__name', 'user__email'],
|
||||
'unique_together': {('business', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
222
core/models.py
222
core/models.py
@ -1,3 +1,221 @@
|
||||
from django.db import models
|
||||
from __future__ import annotations
|
||||
|
||||
# Create your models here.
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class Business(models.Model):
|
||||
name = models.CharField(max_length=160)
|
||||
slug = models.SlugField(unique=True)
|
||||
industry = models.CharField(max_length=120, default='Home Services')
|
||||
primary_city = models.CharField(max_length=120)
|
||||
primary_state = models.CharField(max_length=2)
|
||||
google_review_url = models.URLField(blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name_plural = 'businesses'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def initials(self) -> str:
|
||||
words = [chunk for chunk in self.name.split() if chunk]
|
||||
if not words:
|
||||
return 'TF'
|
||||
return ''.join(word[0] for word in words[:2]).upper()
|
||||
|
||||
|
||||
class BusinessMembership(models.Model):
|
||||
class Role(models.TextChoices):
|
||||
OWNER = 'owner', 'Owner'
|
||||
ADMIN = 'admin', 'Admin'
|
||||
MANAGER = 'manager', 'Manager'
|
||||
TECHNICIAN = 'technician', 'Technician'
|
||||
|
||||
business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='memberships')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='business_memberships')
|
||||
role = models.CharField(max_length=24, choices=Role.choices, default=Role.OWNER)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['business__name', 'user__email']
|
||||
unique_together = ('business', 'user')
|
||||
|
||||
def __str__(self) -> str:
|
||||
identity = self.user.get_full_name() or self.user.email or self.user.username
|
||||
return f'{identity} · {self.business.name} ({self.get_role_display()})'
|
||||
|
||||
@property
|
||||
def can_manage_workspace(self) -> bool:
|
||||
return self.role in {self.Role.OWNER, self.Role.ADMIN}
|
||||
|
||||
@property
|
||||
def can_manage_proof(self) -> bool:
|
||||
return self.role in {self.Role.OWNER, self.Role.ADMIN, self.Role.MANAGER}
|
||||
|
||||
|
||||
class Customer(models.Model):
|
||||
business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='customers')
|
||||
full_name = models.CharField(max_length=160)
|
||||
email = models.EmailField(blank=True)
|
||||
phone = models.CharField(max_length=40, blank=True)
|
||||
city = models.CharField(max_length=120)
|
||||
state = models.CharField(max_length=2)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['full_name']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.full_name} · {self.city}, {self.state}'
|
||||
|
||||
|
||||
class Job(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
COMPLETED = 'completed', 'Completed'
|
||||
REVIEW_REQUESTED = 'review_requested', 'Review requested'
|
||||
PROOF_READY = 'proof_ready', 'Proof ready'
|
||||
|
||||
business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='jobs')
|
||||
customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='jobs')
|
||||
service_type = models.CharField(max_length=120)
|
||||
description = models.TextField(blank=True)
|
||||
technician_name = models.CharField(max_length=120, blank=True)
|
||||
city = models.CharField(max_length=120)
|
||||
state = models.CharField(max_length=2)
|
||||
completed_at = models.DateField(default=timezone.localdate)
|
||||
project_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||
status = models.CharField(max_length=32, choices=Status.choices, default=Status.COMPLETED)
|
||||
is_verified = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-completed_at', '-created_at']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.service_type} for {self.customer.full_name}'
|
||||
|
||||
def _get_media_by_type(self, media_type: str):
|
||||
prefetched_media = getattr(self, '_prefetched_objects_cache', {}).get('media')
|
||||
if prefetched_media is not None:
|
||||
return next((media for media in prefetched_media if media.media_type == media_type), None)
|
||||
return self.media.filter(media_type=media_type).first()
|
||||
|
||||
@property
|
||||
def before_media(self):
|
||||
return self._get_media_by_type(JobMedia.MediaType.BEFORE)
|
||||
|
||||
@property
|
||||
def after_media(self):
|
||||
return self._get_media_by_type(JobMedia.MediaType.AFTER)
|
||||
|
||||
|
||||
def job_media_upload_path(instance: 'JobMedia', filename: str) -> str:
|
||||
return f'jobs/job_{instance.job_id}/{instance.media_type}_{filename}'
|
||||
|
||||
|
||||
class JobMedia(models.Model):
|
||||
class MediaType(models.TextChoices):
|
||||
BEFORE = 'before', 'Before'
|
||||
AFTER = 'after', 'After'
|
||||
|
||||
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='media')
|
||||
media_type = models.CharField(max_length=16, choices=MediaType.choices)
|
||||
file = models.FileField(upload_to=job_media_upload_path, blank=True)
|
||||
caption = models.CharField(max_length=140, blank=True)
|
||||
display_order = models.PositiveSmallIntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ['display_order', 'id']
|
||||
verbose_name_plural = 'job media'
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.job} · {self.get_media_type_display()}'
|
||||
|
||||
|
||||
class ReviewRequest(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
SENT = 'sent', 'Sent'
|
||||
VIEWED = 'viewed', 'Viewed'
|
||||
RESPONDED = 'responded', 'Responded'
|
||||
|
||||
class Channel(models.TextChoices):
|
||||
EMAIL = 'email', 'Email'
|
||||
SMS = 'sms', 'SMS'
|
||||
MANUAL = 'manual', 'Manual share'
|
||||
|
||||
job = models.OneToOneField(Job, on_delete=models.CASCADE, related_name='review_request')
|
||||
token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.SENT)
|
||||
channel = models.CharField(max_length=16, choices=Channel.choices, default=Channel.EMAIL)
|
||||
sent_at = models.DateTimeField(default=timezone.now)
|
||||
last_opened_at = models.DateTimeField(null=True, blank=True)
|
||||
reviewed_at = models.DateTimeField(null=True, blank=True)
|
||||
delivery_note = models.CharField(max_length=200, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-sent_at']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'Review request for {self.job}'
|
||||
|
||||
|
||||
class Feedback(models.Model):
|
||||
class Experience(models.TextChoices):
|
||||
GREAT = 'great', 'Great'
|
||||
GOOD = 'good', 'Good'
|
||||
OKAY = 'okay', 'Okay'
|
||||
BAD = 'bad', 'Bad'
|
||||
|
||||
review_request = models.OneToOneField(ReviewRequest, on_delete=models.CASCADE, related_name='feedback')
|
||||
experience = models.CharField(max_length=12, choices=Experience.choices)
|
||||
rating = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
testimonial = models.TextField(blank=True)
|
||||
follow_up_required = models.BooleanField(default=False)
|
||||
is_public_approved = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'{self.get_experience_display()} feedback for {self.review_request.job}'
|
||||
|
||||
|
||||
class ProofCard(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = 'draft', 'Draft'
|
||||
PUBLISHED = 'published', 'Published'
|
||||
HIDDEN = 'hidden', 'Hidden'
|
||||
|
||||
job = models.OneToOneField(Job, on_delete=models.CASCADE, related_name='proof_card')
|
||||
customer_display_name = models.CharField(max_length=160)
|
||||
is_anonymized = models.BooleanField(default=False)
|
||||
testimonial_quote = models.TextField(blank=True)
|
||||
rating = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT)
|
||||
is_featured = models.BooleanField(default=False)
|
||||
attached_widget_label = models.CharField(max_length=120, blank=True)
|
||||
attached_pages = models.CharField(max_length=200, blank=True)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-is_featured', '-updated_at']
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f'Proof card · {self.job.service_type} · {self.customer_display_name}'
|
||||
|
||||
@property
|
||||
def verified_label(self) -> str:
|
||||
return 'Verified job' if self.job.is_verified else 'Pending verification'
|
||||
|
||||
@ -1,25 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
{% load static %}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Knowledge Base{% endblock %}</title>
|
||||
{% if project_description %}
|
||||
<meta name="description" content="{{ project_description }}">
|
||||
<meta property="og:description" content="{{ project_description }}">
|
||||
<meta property="twitter:description" content="{{ project_description }}">
|
||||
{% endif %}
|
||||
{% if project_image_url %}
|
||||
<meta property="og:image" content="{{ project_image_url }}">
|
||||
<meta property="twitter:image" content="{{ project_image_url }}">
|
||||
{% endif %}
|
||||
{% load static %}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}TrustForge{% endblock %}</title>
|
||||
<meta name="description" content="{% block meta_description %}{{ project_description|default:'TrustForge turns completed jobs into visual proof, testimonials, and conversion assets for service businesses.' }}{% endblock %}">
|
||||
<meta name="author" content="TrustForge">
|
||||
<meta name="keywords" content="proof cards, service business reviews, trust marketing, contractor testimonials, local service SaaS">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="trustforge-body">
|
||||
<div class="tf-background-glow tf-background-glow-1"></div>
|
||||
<div class="tf-background-glow tf-background-glow-2"></div>
|
||||
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
<header class="tf-site-header sticky-top">
|
||||
<nav class="navbar navbar-expand-lg tf-navbar">
|
||||
<div class="container py-2">
|
||||
<a class="navbar-brand tf-brand" href="{% url 'home' %}">
|
||||
<span class="tf-brand-mark">TF</span>
|
||||
<span>TrustForge</span>
|
||||
</a>
|
||||
<button class="navbar-toggler tf-nav-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#tfNav" aria-controls="tfNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="tfNav">
|
||||
<ul class="navbar-nav ms-auto align-items-lg-center gap-lg-2">
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if current_membership %}
|
||||
<li class="nav-item d-none d-lg-flex">
|
||||
<div class="tf-workspace-chip">
|
||||
<span class="tf-workspace-chip-mark">{{ current_membership.business.initials }}</span>
|
||||
<span>
|
||||
<strong>{{ current_membership.business.name }}</strong>
|
||||
<small>{{ current_membership.get_role_display }}</small>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'dashboard' %}">Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'jobs_list' %}">Jobs</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'proof_cards_list' %}">Proof Cards</a></li>
|
||||
<li class="nav-item ms-lg-2"><a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Complete a job</a></li>
|
||||
{% else %}
|
||||
<li class="nav-item"><a class="nav-link" href="{% url 'business_onboarding' %}">Create workspace</a></li>
|
||||
{% endif %}
|
||||
<li class="nav-item dropdown ms-lg-2">
|
||||
<button class="btn tf-user-menu dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="tf-user-menu-label">{{ request.user.first_name|default:request.user.email|truncatechars:18 }}</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end tf-user-dropdown">
|
||||
{% if current_membership %}
|
||||
<li class="dropdown-item-text tf-dropdown-label">
|
||||
<strong>{{ current_membership.business.name }}</strong>
|
||||
<span>{{ current_membership.get_role_display }}</span>
|
||||
</li>
|
||||
<li><a class="dropdown-item" href="{% url 'dashboard' %}">Workspace dashboard</a></li>
|
||||
{% if current_membership.can_manage_workspace %}
|
||||
<li><a class="dropdown-item" href="{% url 'workspace_settings' %}">Workspace settings</a></li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li><a class="dropdown-item" href="{% url 'business_onboarding' %}">Create workspace</a></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" href="{% url 'profile_settings' %}">Profile & 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>
|
||||
|
||||
</html>
|
||||
|
||||
76
core/templates/core/business_onboarding.html
Normal file
76
core/templates/core/business_onboarding.html
Normal file
@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Create Your Workspace | TrustForge{% endblock %}
|
||||
{% block meta_description %}Create your TrustForge business workspace, set your service area, and start the protected proof pipeline.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel sticky-lg-top tf-sticky-panel">
|
||||
<div class="tf-eyebrow">Business onboarding</div>
|
||||
<h1 class="tf-page-title">Create your first TrustForge workspace</h1>
|
||||
<p class="tf-page-subtitle">This workspace becomes the protected home for your completed jobs, review requests, proof cards, and team permissions.</p>
|
||||
<div class="d-grid gap-3 mt-4">
|
||||
<div class="tf-check-row"><span>01</span> Create your business workspace</div>
|
||||
<div class="tf-check-row"><span>02</span> Become the owner automatically</div>
|
||||
<div class="tf-check-row"><span>03</span> Invite admins, managers, and technicians</div>
|
||||
<div class="tf-check-row"><span>04</span> Keep every job and proof asset scoped securely</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="tf-panel">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Workspace details</div>
|
||||
<h2 class="h3 mb-2">Set up the business your team will operate inside</h2>
|
||||
<p class="text-secondary-emphasis mb-0">You can update branding details later from workspace settings. The trust pipeline and team access will use this business as the default scope.</p>
|
||||
</div>
|
||||
<div class="tf-inline-stat">
|
||||
<span>Protected SaaS</span>
|
||||
<strong>Multi-tenant ready</strong>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="row g-3">
|
||||
{% csrf_token %}
|
||||
<div class="col-12">
|
||||
<label class="form-label">Business name</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}<div class="text-danger small mt-1">{{ form.name.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Industry</label>
|
||||
{{ form.industry }}
|
||||
{% if form.industry.errors %}<div class="text-danger small mt-1">{{ form.industry.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Primary city</label>
|
||||
{{ form.primary_city }}
|
||||
{% if form.primary_city.errors %}<div class="text-danger small mt-1">{{ form.primary_city.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">State</label>
|
||||
{{ form.primary_state }}
|
||||
{% if form.primary_state.errors %}<div class="text-danger small mt-1">{{ form.primary_state.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Google review URL <span class="text-secondary-emphasis">(optional)</span></label>
|
||||
{{ form.google_review_url }}
|
||||
{% if form.google_review_url.errors %}<div class="text-danger small mt-1">{{ form.google_review_url.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
{% if form.non_field_errors %}
|
||||
<div class="col-12"><div class="text-danger small">{{ form.non_field_errors|join:', ' }}</div></div>
|
||||
{% endif %}
|
||||
<div class="col-12 pt-2 d-flex flex-wrap gap-3 align-items-center">
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary">Create workspace</button>
|
||||
<span class="small text-secondary-emphasis">You will be assigned as the owner and land inside your dashboard immediately.</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
84
core/templates/core/dashboard.html
Normal file
84
core/templates/core/dashboard.html
Normal file
@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard | TrustForge{% endblock %}
|
||||
{% block meta_description %}TrustForge dashboard for completed jobs, review requests, proof cards, and conversion momentum.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Dashboard</div>
|
||||
<h1 class="tf-page-title">{{ current_membership.business.name }} proof momentum</h1>
|
||||
<p class="tf-page-subtitle">This dashboard is scoped to your current workspace only. Track completed jobs, response volume, published proof, and the conversion signal your team is creating this week.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' current_membership.business.slug %}" target="_blank" rel="noopener">View public gallery</a>
|
||||
{% if current_membership.can_manage_workspace %}<a class="btn tf-btn tf-btn-secondary" href="{% url 'workspace_settings' %}">Workspace settings</a>{% endif %}
|
||||
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Log a new completed job</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ stats.completed_jobs|default:0 }}</span><p>Jobs completed</p></div></div>
|
||||
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ stats.review_requests|default:0 }}</span><p>Review requests</p></div></div>
|
||||
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ stats.proof_cards|default:0 }}</span><p>Proof cards created</p></div></div>
|
||||
<div class="col-md-6 col-xl-3"><div class="tf-metric-card"><span>{{ conversion_rate }}%</span><p>Positive response rate</p></div></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<div class="tf-panel h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h4 mb-0">Recent jobs</h2>
|
||||
<a href="{% url 'jobs_list' %}" class="tf-inline-link">View all</a>
|
||||
</div>
|
||||
<div class="d-grid gap-3">
|
||||
{% for job in recent_jobs %}
|
||||
<a href="{% url 'job_detail' job.id %}" class="tf-activity-link">
|
||||
<div class="tf-activity-row tf-activity-row-soft">
|
||||
<div>
|
||||
<strong>{{ job.service_type }}</strong>
|
||||
<div class="small text-secondary-emphasis">{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }}</div>
|
||||
</div>
|
||||
<span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% empty %}
|
||||
<div class="tf-empty-state text-start">
|
||||
<h3 class="h5 mb-2">No jobs yet</h3>
|
||||
<p class="text-secondary-emphasis mb-0">Use the field intake form to start the first proof workflow for {{ current_membership.business.name }}.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h4 mb-0">Recent proof cards</h2>
|
||||
<a href="{% url 'proof_cards_list' %}" class="tf-inline-link">Open gallery</a>
|
||||
</div>
|
||||
<div class="d-grid gap-3">
|
||||
{% for proof in recent_proofs %}
|
||||
<a href="{% url 'proof_card_detail' proof.id %}" class="tf-proof-mini">
|
||||
<div>
|
||||
<div class="tf-card-tag mb-2">{{ proof.job.service_type }}</div>
|
||||
<strong>{{ proof.job.city }}, {{ proof.job.state }}</strong>
|
||||
<div class="small text-secondary-emphasis">{{ proof.customer_display_name }}</div>
|
||||
</div>
|
||||
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
|
||||
</a>
|
||||
{% empty %}
|
||||
<div class="tf-empty-state text-start">
|
||||
<h3 class="h5 mb-2">No proof cards yet</h3>
|
||||
<p class="text-secondary-emphasis mb-0">Every completed job inside this workspace instantly creates a draft proof card.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
14
core/templates/core/includes/proof_media_grid.html
Normal file
14
core/templates/core/includes/proof_media_grid.html
Normal file
@ -0,0 +1,14 @@
|
||||
<div class="tf-proof-media-grid{% if grid_class %} {{ grid_class }}{% endif %}">
|
||||
<div class="tf-photo-slot{% if job.before_media and job.before_media.file %} tf-photo-slot-has-media{% endif %}">
|
||||
{% if job.before_media and job.before_media.file %}
|
||||
<img src="{{ job.before_media.file.url }}" alt="Before photo for {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}" class="tf-photo-image" width="960" height="720">
|
||||
{% endif %}
|
||||
<span class="tf-photo-label">Before</span>
|
||||
</div>
|
||||
<div class="tf-photo-slot tf-photo-slot-after{% if job.after_media and job.after_media.file %} tf-photo-slot-has-media{% endif %}">
|
||||
{% if job.after_media and job.after_media.file %}
|
||||
<img src="{{ job.after_media.file.url }}" alt="After photo for {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}" class="tf-photo-image" width="960" height="720">
|
||||
{% endif %}
|
||||
<span class="tf-photo-label">After</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,145 +1,195 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ project_name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100' viewBox='0 0 100 100'><path d='M-10 10L110 10M10 -10L10 110' stroke-width='1' stroke='rgba(255,255,255,0.05)'/></svg>");
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes bg-pan {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.2rem, 3vw + 1.2rem, 3.2rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 1.2rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.loader {
|
||||
margin: 1.5rem auto;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.runtime code {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block title %}TrustForge | Turn completed jobs into proof that wins the next customer{% endblock %}
|
||||
{% block meta_description %}TrustForge helps service businesses transform completed jobs into proof cards, review requests, and conversion assets that win more booked work.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your app…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<section class="tf-hero-section py-5 py-lg-6">
|
||||
<div class="container py-lg-5">
|
||||
<div class="row align-items-center g-5">
|
||||
<div class="col-lg-6">
|
||||
<div class="tf-eyebrow mb-3">Trust engine for service businesses</div>
|
||||
<h1 class="tf-display mb-4">Every completed job becomes proof that closes the next one.</h1>
|
||||
<p class="tf-lead mb-4">TrustForge gives contractors, roofers, HVAC teams, plumbers, electricians, junk removal crews, and landscapers a fast field workflow: finish the job, send the review request, generate a premium proof card, and publish conversion-ready assets.</p>
|
||||
<div class="d-flex flex-wrap gap-3 mb-4">
|
||||
<a class="btn tf-btn tf-btn-primary btn-lg" href="{% url 'job_create' %}">Start a completed job</a>
|
||||
<a class="btn tf-btn tf-btn-secondary btn-lg" href="{% url 'dashboard' %}">View dashboard</a>
|
||||
</div>
|
||||
<div class="row g-3 tf-stat-row">
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="tf-stat-chip">
|
||||
<span>{{ stats.completed_jobs|default:0 }}</span>
|
||||
Jobs logged
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="tf-stat-chip">
|
||||
<span>{{ stats.review_requests|default:0 }}</span>
|
||||
Requests sent
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="tf-stat-chip">
|
||||
<span>{{ stats.proof_cards|default:0 }}</span>
|
||||
Proof cards
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<div class="tf-stat-chip">
|
||||
<span>{{ business_count }}</span>
|
||||
Active businesses
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="tf-device-card">
|
||||
<div class="tf-device-header">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="tf-device-body">
|
||||
<div class="tf-mini-step active">1. Job completed</div>
|
||||
<div class="tf-mini-step active">2. Review requested</div>
|
||||
<div class="tf-mini-step active">3. Proof created</div>
|
||||
<div class="tf-proof-preview mt-4">
|
||||
{% if featured_proofs %}
|
||||
{% with proof=featured_proofs.0 %}
|
||||
<div class="mb-3">
|
||||
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||
<div>
|
||||
<div class="tf-card-tag">{{ proof.job.service_type }}</div>
|
||||
<h2 class="tf-card-title h4 mt-2 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
|
||||
<div class="text-secondary-emphasis small">Verified completion · {{ proof.job.completed_at|date:"M j, Y" }}</div>
|
||||
</div>
|
||||
<div class="tf-rating-pill">{% if proof.rating %}★ {{ proof.rating }}.0{% else %}Verified{% endif %}</div>
|
||||
</div>
|
||||
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:120 }}”{% else %}Premium proof cards turn real field work into a conversion asset that belongs on your homepage and service pages.{% endif %}</p>
|
||||
<div class="d-flex justify-content-between small text-secondary-emphasis">
|
||||
<span>{{ proof.customer_display_name }}</span>
|
||||
<span>{{ proof.verified_label }}</span>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<div class="tf-empty-proof">
|
||||
<div class="tf-proof-media-grid mb-3">
|
||||
<div class="tf-photo-slot">Before</div>
|
||||
<div class="tf-photo-slot tf-photo-slot-after">After</div>
|
||||
</div>
|
||||
<h2 class="h4 mb-2">Your first proof card appears here</h2>
|
||||
<p class="mb-0 text-secondary-emphasis">Log a completed job to instantly generate the draft card, review request, and proof pipeline.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">AppWizzy AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will refresh automatically as the plan is implemented.</p>
|
||||
<p class="runtime">
|
||||
Runtime: Django <code>{{ django_version }}</code> · Python <code>{{ python_version }}</code>
|
||||
— UTC <code>{{ current_time|date:"Y-m-d H:i:s" }}</code>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC)
|
||||
</footer>
|
||||
{% endblock %}
|
||||
</section>
|
||||
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-4">
|
||||
<div class="tf-panel h-100">
|
||||
<div class="tf-panel-icon">⚡</div>
|
||||
<h2 class="h4 mb-3">Fast field workflow</h2>
|
||||
<p class="mb-0 text-secondary-emphasis">Capture a completed job, upload before/after photos, and send a review request from a mobile-friendly form designed for technicians on-site.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="tf-panel h-100">
|
||||
<div class="tf-panel-icon">🛡️</div>
|
||||
<h2 class="h4 mb-3">Proof-first cards</h2>
|
||||
<p class="mb-0 text-secondary-emphasis">Every job creates a premium proof card with service, location, photos, verification status, testimonial, and publishing controls.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="tf-panel h-100">
|
||||
<div class="tf-panel-icon">📈</div>
|
||||
<h2 class="h4 mb-3">Conversion-ready assets</h2>
|
||||
<p class="mb-0 text-secondary-emphasis">Feature standout work on your landing page, service pages, and proof gallery to give new customers visible confidence.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pb-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-end flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Featured proof</div>
|
||||
<h2 class="tf-section-title">Recent conversion assets</h2>
|
||||
</div>
|
||||
{% if request.user.is_authenticated and current_membership %}
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_cards_list' %}">Open your proof cards</a>
|
||||
{% else %}
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'signup' %}">Get started free</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
{% for proof in featured_proofs %}
|
||||
<div class="col-lg-4">
|
||||
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' proof.job.business.slug proof.id %}">
|
||||
<article class="tf-proof-card h-100">
|
||||
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
|
||||
<div class="tf-proof-card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
|
||||
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
|
||||
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
|
||||
</div>
|
||||
<h3 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h3>
|
||||
<p class="small text-secondary-emphasis mb-3">Completed {{ proof.job.completed_at|date:"M j, Y" }}</p>
|
||||
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:115 }}”{% else %}A ready-to-publish proof card waiting for review feedback and promotion.{% endif %}</p>
|
||||
<div class="d-flex justify-content-between small text-secondary-emphasis">
|
||||
<span>{{ proof.customer_display_name }}</span>
|
||||
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}No rating yet{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12">
|
||||
<div class="tf-empty-state text-center">
|
||||
<h3 class="h4 mb-2">No proof cards featured yet</h3>
|
||||
<p class="text-secondary-emphasis mb-4">Create your first completed job to populate the TrustForge proof gallery.</p>
|
||||
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Create the first job</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pb-5">
|
||||
<div class="container">
|
||||
<div class="tf-panel tf-panel-dark">
|
||||
<div class="row g-4 align-items-center">
|
||||
<div class="col-lg-7">
|
||||
<div class="tf-eyebrow tf-eyebrow-light">Pipeline</div>
|
||||
<h2 class="tf-section-title text-white">Job Completed → Review Requested → Proof Card Created → Displayed → Converts Next Customer</h2>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="d-grid gap-3">
|
||||
{% for job in recent_jobs %}
|
||||
<div class="tf-activity-row">
|
||||
<div>
|
||||
<strong>{{ job.service_type }}</strong>
|
||||
<div class="small text-secondary-emphasis">{{ job.business.name }} · {{ job.city }}, {{ job.state }}</div>
|
||||
</div>
|
||||
<span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="mb-0 text-secondary-emphasis">No jobs yet. The first intake will immediately show up here.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
78
core/templates/core/job_detail.html
Normal file
78
core/templates/core/job_detail.html
Normal file
@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ job.service_type }} | TrustForge{% endblock %}
|
||||
{% block meta_description %}TrustForge job detail for {{ job.service_type }} in {{ job.city }}, {{ job.state }}.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Job detail</div>
|
||||
<h1 class="tf-page-title">{{ job.service_type }}</h1>
|
||||
<p class="tf-page-subtitle">{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }} · Completed {{ job.completed_at|date:"F j, Y" }}</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'jobs_list' %}">All jobs</a>
|
||||
<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_detail' job.proof_card.id %}">Open proof card</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<div class="tf-panel h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h4 mb-0">Job summary</h2>
|
||||
<span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6"><div class="tf-detail-box"><span>Business</span><strong>{{ job.business.name }}</strong></div></div>
|
||||
<div class="col-md-6"><div class="tf-detail-box"><span>Technician</span><strong>{{ job.technician_name|default:'Not added yet' }}</strong></div></div>
|
||||
<div class="col-md-6"><div class="tf-detail-box"><span>Project value</span><strong>{% if job.project_value %}${{ job.project_value }}{% else %}Optional{% endif %}</strong></div></div>
|
||||
<div class="col-md-6"><div class="tf-detail-box"><span>Verified</span><strong>{% if job.is_verified %}Yes{% else %}Pending{% endif %}</strong></div></div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h3 class="h6 text-uppercase text-secondary-emphasis mb-2">Description</h3>
|
||||
<p class="mb-0">{{ job.description|default:'No extra description was added for this completed job.' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="h6 text-uppercase text-secondary-emphasis mb-2">Job photos</h3>
|
||||
{% include "core/includes/proof_media_grid.html" with job=job %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel mb-4">
|
||||
<h2 class="h4 mb-3">Review request</h2>
|
||||
{% if job.review_request %}
|
||||
<div class="tf-detail-box mb-3"><span>Status</span><strong>{{ job.review_request.get_status_display }}</strong></div>
|
||||
<div class="tf-detail-box mb-3"><span>Channel</span><strong>{{ job.review_request.get_channel_display }}</strong></div>
|
||||
<div class="tf-detail-box mb-3"><span>Delivery note</span><strong>{{ job.review_request.delivery_note|default:'Ready to share' }}</strong></div>
|
||||
<a href="{% url 'review_request' job.review_request.token %}" class="btn tf-btn tf-btn-secondary w-100">Open customer review page</a>
|
||||
{% else %}
|
||||
<p class="text-secondary-emphasis">No review request has been sent for this job yet.</p>
|
||||
<form method="post" class="d-grid gap-3">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="send_review_request">
|
||||
<select name="channel" class="form-select">
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="manual">Manual share</option>
|
||||
</select>
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary">Send review request</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="tf-panel">
|
||||
<h2 class="h4 mb-3">Proof card</h2>
|
||||
<div class="tf-detail-box mb-3"><span>Status</span><strong>{{ job.proof_card.get_status_display }}</strong></div>
|
||||
<div class="tf-detail-box mb-3"><span>Display name</span><strong>{{ job.proof_card.customer_display_name }}</strong></div>
|
||||
<div class="tf-detail-box mb-4"><span>Widget target</span><strong>{{ job.proof_card.attached_widget_label|default:'Not set' }}</strong></div>
|
||||
<a href="{% url 'proof_card_detail' job.proof_card.id %}" class="btn tf-btn tf-btn-primary w-100">Manage proof card</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
114
core/templates/core/job_form.html
Normal file
114
core/templates/core/job_form.html
Normal file
@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Complete a Job | TrustForge{% endblock %}
|
||||
{% block meta_description %}Log a completed service job in TrustForge, upload before and after photos, and trigger the proof workflow.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-start">
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel sticky-lg-top tf-sticky-panel">
|
||||
<div class="tf-eyebrow">Technician flow</div>
|
||||
<h1 class="tf-page-title">Complete a job in under 30 seconds</h1>
|
||||
<p class="tf-page-subtitle">You are logging this inside <strong>{{ current_membership.business.name }}</strong>. Add the customer, upload photos, and trigger a review request. TrustForge creates the proof card automatically.</p>
|
||||
<div class="d-grid gap-3 mt-4">
|
||||
<div class="tf-check-row"><span>01</span> Save the completed job</div>
|
||||
<div class="tf-check-row"><span>02</span> Draft proof card is generated</div>
|
||||
<div class="tf-check-row"><span>03</span> Review request is ready to share</div>
|
||||
<div class="tf-check-row"><span>04</span> Positive feedback can auto-publish proof</div>
|
||||
</div>
|
||||
<div class="tf-detail-box mt-4"><span>Workspace scope</span><strong>{{ current_membership.business.primary_city }}, {{ current_membership.business.primary_state }} · {{ current_membership.get_role_display }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="tf-panel">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Job intake</div>
|
||||
<h2 class="h3 mb-2">Capture the completed work</h2>
|
||||
<p class="text-secondary-emphasis mb-0">Every field below feeds the trust pipeline: completed job → review request → proof card.</p>
|
||||
</div>
|
||||
<div class="tf-inline-stat">
|
||||
<span>Business scope</span>
|
||||
<strong>{{ current_membership.business.name }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" enctype="multipart/form-data" class="row g-3">
|
||||
{% csrf_token %}
|
||||
{{ form.business }}
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Customer name</label>
|
||||
{{ form.customer_name }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Service type</label>
|
||||
{{ form.service_type }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Customer email</label>
|
||||
{{ form.customer_email }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Customer phone</label>
|
||||
{{ form.customer_phone }}
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label">City</label>
|
||||
{{ form.customer_city }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">State</label>
|
||||
{{ form.customer_state }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Technician</label>
|
||||
{{ form.technician_name }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Completion date</label>
|
||||
{{ form.completion_date }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Project value</label>
|
||||
{{ form.project_value }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Review channel</label>
|
||||
{{ form.review_channel }}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Job description</label>
|
||||
{{ form.description }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Before photo</label>
|
||||
{{ form.before_photo }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">After photo</label>
|
||||
{{ form.after_photo }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check tf-check-field">
|
||||
{{ form.anonymize_customer }}
|
||||
<label class="form-check-label" for="{{ form.anonymize_customer.id_for_label }}">Anonymize customer on proof card</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check tf-check-field">
|
||||
{{ form.send_review_request }}
|
||||
<label class="form-check-label" for="{{ form.send_review_request.id_for_label }}">Send review request after save</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 pt-2 d-flex flex-wrap gap-3">
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary">Save completed job</button>
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'jobs_list' %}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
59
core/templates/core/jobs_list.html
Normal file
59
core/templates/core/jobs_list.html
Normal file
@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Jobs | TrustForge{% endblock %}
|
||||
{% block meta_description %}Browse completed jobs in TrustForge and open each proof workflow detail.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Jobs</div>
|
||||
<h1 class="tf-page-title">{{ current_membership.business.name }} completed job pipeline</h1>
|
||||
<p class="tf-page-subtitle">Every job below belongs only to this workspace and can lead to a proof card, published testimonial, and higher conversion confidence.</p>
|
||||
</div>
|
||||
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Add completed job</a>
|
||||
</div>
|
||||
|
||||
<div class="tf-panel p-0 overflow-hidden">
|
||||
{% if jobs %}
|
||||
<div class="table-responsive">
|
||||
<table class="table tf-table align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Service</th>
|
||||
<th>Location</th>
|
||||
<th>Completed</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ job.customer.full_name }}</strong>
|
||||
<div class="small text-secondary-emphasis">{{ job.technician_name|default:'Technician not added yet' }}</div>
|
||||
</td>
|
||||
<td>{{ job.service_type }}</td>
|
||||
<td>{{ job.city }}, {{ job.state }}</td>
|
||||
<td>{{ job.completed_at|date:"M j, Y" }}</td>
|
||||
<td><span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span></td>
|
||||
<td class="text-end"><a href="{% url 'job_detail' job.id %}" class="tf-inline-link">Open</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="tf-empty-state text-center m-4">
|
||||
<h2 class="h4 mb-2">No jobs completed yet</h2>
|
||||
<p class="text-secondary-emphasis mb-4">Start with one field intake and TrustForge will spin up the proof workflow automatically for this business.</p>
|
||||
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Create first job</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
77
core/templates/core/profile_settings.html
Normal file
77
core/templates/core/profile_settings.html
Normal file
@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile & Settings | TrustForge{% endblock %}
|
||||
{% block meta_description %}Manage your TrustForge profile, workspace memberships, and account settings.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Profile settings</div>
|
||||
<h1 class="tf-page-title">Your account identity</h1>
|
||||
<p class="tf-page-subtitle">Update your login profile and review which TrustForge workspaces you belong to.</p>
|
||||
</div>
|
||||
{% if current_membership and current_membership.can_manage_workspace %}
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'workspace_settings' %}">Workspace settings</a>
|
||||
{% elif not current_membership %}
|
||||
<a class="btn tf-btn tf-btn-primary" href="{% url 'business_onboarding' %}">Create workspace</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<div class="tf-panel h-100">
|
||||
<h2 class="h4 mb-3">Account details</h2>
|
||||
<form method="post" class="row g-3">
|
||||
{% csrf_token %}
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">First name</label>
|
||||
{{ form.first_name }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Last name</label>
|
||||
{{ form.last_name }}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Email</label>
|
||||
{{ form.email }}
|
||||
<div class="small text-secondary-emphasis mt-2">This email is also your login and password reset destination.</div>
|
||||
</div>
|
||||
<div class="col-12 pt-2">
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary">Save profile</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel h-100">
|
||||
<h2 class="h4 mb-3">Workspace access</h2>
|
||||
<div class="d-grid gap-3">
|
||||
{% for membership in memberships %}
|
||||
<div class="tf-team-member{% if current_membership and membership.business_id == current_membership.business_id %} tf-team-member-active{% endif %}">
|
||||
<div>
|
||||
<strong>{{ membership.business.name }}</strong>
|
||||
<div class="small text-secondary-emphasis">{{ membership.business.primary_city }}, {{ membership.business.primary_state }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="tf-status-pill tf-role-pill">{{ membership.get_role_display }}</span>
|
||||
{% if current_membership and membership.business_id == current_membership.business_id %}
|
||||
<div class="small text-secondary-emphasis mt-1">Current workspace</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="tf-empty-state text-start">
|
||||
<h3 class="h5 mb-2">No workspace yet</h3>
|
||||
<p class="text-secondary-emphasis mb-4">Create your first business workspace to turn TrustForge into a real tenant-scoped SaaS account.</p>
|
||||
<a class="btn tf-btn tf-btn-primary" href="{% url 'business_onboarding' %}">Create workspace</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
84
core/templates/core/proof_card_detail.html
Normal file
84
core/templates/core/proof_card_detail.html
Normal file
@ -0,0 +1,84 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Proof Card | TrustForge{% endblock %}
|
||||
{% block meta_description %}View a TrustForge proof card and manage publishing controls for the active workspace.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Proof card</div>
|
||||
<h1 class="tf-page-title">{{ proof_card.job.service_type }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}</h1>
|
||||
<p class="tf-page-subtitle">{{ proof_card.customer_display_name }} · Scoped to {{ proof_card.job.business.name }} · Completed {{ proof_card.job.completed_at|date:"F j, Y" }}</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_cards_list' %}">All proof cards</a>
|
||||
{% if proof_card.status == 'published' %}<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_detail' proof_card.job.business.slug proof_card.id %}" target="_blank" rel="noopener">Open public proof</a>{% endif %}
|
||||
{% if current_membership.can_manage_proof %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_edit' proof_card.id %}">Edit proof card</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<article class="tf-proof-card tf-proof-card-expanded h-100">
|
||||
{% include "core/includes/proof_media_grid.html" with job=proof_card.job grid_class='tf-proof-media-grid-large' %}
|
||||
<div class="tf-proof-card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
|
||||
<span class="tf-card-tag">{{ proof_card.job.service_type }}</span>
|
||||
<span class="tf-badge-verified">{{ proof_card.verified_label }}</span>
|
||||
</div>
|
||||
<blockquote class="tf-testimonial mb-4">{% if proof_card.testimonial_quote %}“{{ proof_card.testimonial_quote }}”{% else %}Add a testimonial or collect one through the review request page to strengthen this proof asset.{% endif %}</blockquote>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><div class="tf-detail-box"><span>Rating</span><strong>{% if proof_card.rating %}★ {{ proof_card.rating }} / 5{% else %}Pending{% endif %}</strong></div></div>
|
||||
<div class="col-md-4"><div class="tf-detail-box"><span>Featured</span><strong>{% if proof_card.is_featured %}Yes{% else %}No{% endif %}</strong></div></div>
|
||||
<div class="col-md-4"><div class="tf-detail-box"><span>Widget target</span><strong>{{ proof_card.attached_widget_label|default:'Not set' }}</strong></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel mb-4">
|
||||
<h2 class="h4 mb-3">Quick actions</h2>
|
||||
{% if current_membership.can_manage_proof %}
|
||||
<div class="d-grid gap-3">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="publish">
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Publish proof card</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="hide">
|
||||
<button type="submit" class="btn tf-btn tf-btn-secondary w-100">Hide from public proof gallery</button>
|
||||
</form>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="toggle_featured">
|
||||
<button type="submit" class="btn tf-btn tf-btn-secondary w-100">{% if proof_card.is_featured %}Remove from featured proof{% else %}Feature on landing experience{% endif %}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="tf-empty-state text-start">
|
||||
<h3 class="h5 mb-2">View-only access</h3>
|
||||
<p class="text-secondary-emphasis mb-0">Your current role can view proof assets in this workspace but cannot change publishing controls.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="tf-panel">
|
||||
<h2 class="h4 mb-3">Placement</h2>
|
||||
<div class="tf-detail-box mb-3"><span>Attach to widgets</span><strong>{{ proof_card.attached_widget_label|default:'Homepage proof gallery' }}</strong></div>
|
||||
<div class="tf-detail-box mb-3"><span>Attach to pages</span><strong>{{ proof_card.attached_pages|default:'Homepage' }}</strong></div>
|
||||
<div class="tf-detail-box mb-3"><span>Public gallery</span><strong><a class="tf-inline-link" href="{% url 'public_proof_gallery' proof_card.job.business.slug %}" target="_blank" rel="noopener">/proof/{{ proof_card.job.business.slug }}/</a></strong></div>
|
||||
{% if proof_card.status == 'published' %}
|
||||
<div class="tf-detail-box mb-3"><span>Public proof page</span><strong><a class="tf-inline-link" href="{% url 'public_proof_detail' proof_card.job.business.slug proof_card.id %}" target="_blank" rel="noopener">/proof/{{ proof_card.job.business.slug }}/{{ proof_card.id }}/</a></strong></div>
|
||||
{% endif %}
|
||||
{% if proof_card.job.review_request %}
|
||||
<a class="btn tf-btn tf-btn-secondary w-100" href="{% url 'review_request' proof_card.job.review_request.token %}">Open review request page</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
65
core/templates/core/proof_card_form.html
Normal file
65
core/templates/core/proof_card_form.html
Normal file
@ -0,0 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Edit Proof Card | TrustForge{% endblock %}
|
||||
{% block meta_description %}Edit a TrustForge proof card’s testimonial, status, featuring, and placement settings.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Edit proof card</div>
|
||||
<h1 class="tf-page-title">Refine the conversion asset</h1>
|
||||
<p class="tf-page-subtitle">Update the customer display name, testimonial, placement, and publish settings without touching the underlying job record.</p>
|
||||
</div>
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_card_detail' proof_card.id %}">Back to proof card</a>
|
||||
</div>
|
||||
|
||||
<div class="tf-panel">
|
||||
<form method="post" class="row g-3">
|
||||
{% csrf_token %}
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Customer display name</label>
|
||||
{{ form.customer_display_name }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Rating</label>
|
||||
{{ form.rating }}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Testimonial quote</label>
|
||||
{{ form.testimonial_quote }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Status</label>
|
||||
{{ form.status }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Attach to widget</label>
|
||||
{{ form.attached_widget_label }}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Attach to pages</label>
|
||||
{{ form.attached_pages }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check tf-check-field">
|
||||
{{ form.is_anonymized }}
|
||||
<label class="form-check-label" for="{{ form.is_anonymized.id_for_label }}">Anonymize customer</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check tf-check-field">
|
||||
{{ form.is_featured }}
|
||||
<label class="form-check-label" for="{{ form.is_featured.id_for_label }}">Feature this proof card</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-3 pt-2">
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary">Save proof card</button>
|
||||
<a href="{% url 'proof_card_detail' proof_card.id %}" class="btn tf-btn tf-btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
58
core/templates/core/proof_cards_list.html
Normal file
58
core/templates/core/proof_cards_list.html
Normal file
@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Proof Cards | TrustForge{% endblock %}
|
||||
{% block meta_description %}Browse TrustForge proof cards scoped to your current workspace.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Proof cards</div>
|
||||
<h1 class="tf-page-title">{{ current_membership.business.name }} proof gallery</h1>
|
||||
<p class="tf-page-subtitle">These conversion assets are tenant-scoped to the active workspace and ready for review, publishing, or featuring.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' current_membership.business.slug %}" target="_blank" rel="noopener">Open public gallery</a>
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{% for proof in proof_cards %}
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<a class="tf-proof-card-link" href="{% url 'proof_card_detail' proof.id %}">
|
||||
<article class="tf-proof-card h-100">
|
||||
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
|
||||
<div class="tf-proof-card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
|
||||
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
|
||||
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
|
||||
</div>
|
||||
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
|
||||
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
|
||||
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:110 }}”{% else %}This job is ready to become a published proof asset for the next customer.{% endif %}</p>
|
||||
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
|
||||
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
|
||||
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}No rating yet{% endif %}</span>
|
||||
</div>
|
||||
{% if proof.status == 'published' %}
|
||||
<p class="small text-secondary-emphasis mt-3 mb-0">Live at <span class="tf-inline-link">/proof/{{ current_membership.business.slug }}/{{ proof.id }}/</span></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12">
|
||||
<div class="tf-empty-state text-center">
|
||||
<h2 class="h4 mb-2">No proof cards yet</h2>
|
||||
<p class="text-secondary-emphasis mb-4">Log a completed job to create the first proof asset in this workspace.</p>
|
||||
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Create first job</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
74
core/templates/core/public_proof_detail.html
Normal file
74
core/templates/core/public_proof_detail.html
Normal file
@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ proof_card.job.service_type }} in {{ proof_card.job.city }} | {{ business.name }} Proof{% endblock %}
|
||||
{% block meta_description %}Published proof from {{ business.name }} for {{ proof_card.job.service_type|lower }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Published proof</div>
|
||||
<h1 class="tf-page-title">{{ proof_card.job.service_type }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}</h1>
|
||||
<p class="tf-page-subtitle">Completed by {{ business.name }} · {{ proof_card.customer_display_name }} · {{ proof_card.job.completed_at|date:"F j, Y" }}</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' business.slug %}">Back to gallery</a>
|
||||
{% if business.google_review_url %}<a class="btn tf-btn tf-btn-primary" href="{{ business.google_review_url }}" target="_blank" rel="noopener">Read more reviews</a>{% elif request.user.is_authenticated and current_membership and current_membership.business_id == business.id %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_detail' proof_card.id %}">Manage this proof card</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<article class="tf-proof-card tf-proof-card-expanded h-100">
|
||||
{% include "core/includes/proof_media_grid.html" with job=proof_card.job grid_class='tf-proof-media-grid-large' %}
|
||||
<div class="tf-proof-card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
|
||||
<span class="tf-card-tag">{{ proof_card.job.service_type }}</span>
|
||||
<span class="tf-badge-verified">{{ proof_card.verified_label }}</span>
|
||||
</div>
|
||||
<blockquote class="tf-testimonial mb-4">{% if proof_card.testimonial_quote %}“{{ proof_card.testimonial_quote }}”{% else %}This published proof card confirms a completed job by {{ business.name }}.{% endif %}</blockquote>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4"><div class="tf-detail-box"><span>Rating</span><strong>{% if proof_card.rating %}★ {{ proof_card.rating }} / 5{% else %}Verified{% endif %}</strong></div></div>
|
||||
<div class="col-md-4"><div class="tf-detail-box"><span>Completed</span><strong>{{ proof_card.job.completed_at|date:"M j, Y" }}</strong></div></div>
|
||||
<div class="col-md-4"><div class="tf-detail-box"><span>Service area</span><strong>{{ proof_card.job.city }}, {{ proof_card.job.state }}</strong></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel mb-4">
|
||||
<h2 class="h4 mb-3">Why this matters</h2>
|
||||
<div class="d-grid gap-3">
|
||||
<div class="tf-detail-box"><span>Business</span><strong>{{ business.name }}</strong></div>
|
||||
<div class="tf-detail-box"><span>Customer display</span><strong>{{ proof_card.customer_display_name }}</strong></div>
|
||||
<div class="tf-detail-box"><span>Proof status</span><strong>{{ proof_card.get_status_display }}</strong></div>
|
||||
<div class="tf-detail-box"><span>Placement</span><strong>{{ proof_card.attached_pages|default:'Homepage' }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tf-panel">
|
||||
<h2 class="h4 mb-3">More published proof</h2>
|
||||
<div class="d-grid gap-3">
|
||||
{% for related in related_proofs %}
|
||||
<a href="{% url 'public_proof_detail' business.slug related.id %}" class="tf-proof-mini">
|
||||
<div>
|
||||
<div class="tf-card-tag mb-2">{{ related.job.service_type }}</div>
|
||||
<strong>{{ related.job.city }}, {{ related.job.state }}</strong>
|
||||
<div class="small text-secondary-emphasis">{{ related.customer_display_name }}</div>
|
||||
</div>
|
||||
<span class="tf-status-pill tf-status-{{ related.status }}">{{ related.get_status_display }}</span>
|
||||
</a>
|
||||
{% empty %}
|
||||
<div class="tf-empty-state text-start">
|
||||
<h3 class="h5 mb-2">This is the first published proof card</h3>
|
||||
<p class="text-secondary-emphasis mb-0">Return to the gallery later as {{ business.name }} publishes more completed work.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
94
core/templates/core/public_proof_gallery.html
Normal file
94
core/templates/core/public_proof_gallery.html
Normal file
@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ business.name }} Proof Gallery | TrustForge{% endblock %}
|
||||
{% block meta_description %}Browse published proof cards for {{ business.name }} in {{ business.primary_city }}, {{ business.primary_state }}.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Public proof gallery</div>
|
||||
<h1 class="tf-page-title">{{ business.name }} completed work</h1>
|
||||
<p class="tf-page-subtitle">Published proof cards from real completed jobs in {{ business.primary_city }}, {{ business.primary_state }}. Use this gallery to validate the quality, consistency, and trust signal behind the brand.</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% if business.google_review_url %}<a class="btn tf-btn tf-btn-secondary" href="{{ business.google_review_url }}" target="_blank" rel="noopener">Read Google reviews</a>{% endif %}
|
||||
{% if request.user.is_authenticated and current_membership and current_membership.business_id == business.id %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_cards_list' %}">Manage proof cards</a>{% else %}<a class="btn tf-btn tf-btn-primary" href="{% url 'signup' %}">Create your own gallery</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if featured_proofs %}
|
||||
<div class="tf-panel mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3 mb-3">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Featured proof</div>
|
||||
<h2 class="h3 mb-0">Standout published jobs</h2>
|
||||
</div>
|
||||
<div class="small text-secondary-emphasis">{{ proof_cards|length }} published proof card{{ proof_cards|length|pluralize }}</div>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
{% for proof in featured_proofs %}
|
||||
<div class="col-lg-4">
|
||||
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' business.slug proof.id %}">
|
||||
<article class="tf-proof-card h-100">
|
||||
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
|
||||
<div class="tf-proof-card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
|
||||
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
|
||||
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
|
||||
</div>
|
||||
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
|
||||
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
|
||||
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:110 }}”{% else %}Published proof from a real completed job by {{ business.name }}.{% endif %}</p>
|
||||
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
|
||||
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
|
||||
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}Verified{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-4">
|
||||
{% for proof in proof_cards %}
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' business.slug proof.id %}">
|
||||
<article class="tf-proof-card h-100">
|
||||
<div class="tf-proof-media-grid">
|
||||
<div class="tf-photo-slot">Before</div>
|
||||
<div class="tf-photo-slot tf-photo-slot-after">After</div>
|
||||
</div>
|
||||
<div class="tf-proof-card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
|
||||
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
|
||||
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
|
||||
</div>
|
||||
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
|
||||
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
|
||||
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:120 }}”{% else %}Published proof asset ready for customers comparing local providers.{% endif %}</p>
|
||||
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
|
||||
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
|
||||
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}Verified{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12">
|
||||
<div class="tf-empty-state text-center">
|
||||
<h2 class="h4 mb-2">No public proof cards yet</h2>
|
||||
<p class="text-secondary-emphasis mb-4">{{ business.name }} has not published any proof cards yet. Check back soon for verified completed work.</p>
|
||||
<a class="btn tf-btn tf-btn-primary" href="{% url 'signup' %}">Build a proof gallery like this</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
54
core/templates/core/review_request.html
Normal file
54
core/templates/core/review_request.html
Normal file
@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Share Feedback | TrustForge{% endblock %}
|
||||
{% block meta_description %}Share feedback on your recent service experience and help create verified proof of work.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="tf-panel tf-panel-centered">
|
||||
<div class="tf-eyebrow">Customer feedback</div>
|
||||
<h1 class="tf-page-title">How was your experience?</h1>
|
||||
<p class="tf-page-subtitle">{{ job.business.name }} completed {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}. Your feedback helps verify real work and guide follow-up.</p>
|
||||
|
||||
{% if submitted %}
|
||||
<div class="tf-empty-state text-center mt-4">
|
||||
{% if positive %}
|
||||
<h2 class="h3 mb-3">Thank you — this proof is ready to help the next customer.</h2>
|
||||
<p class="text-secondary-emphasis mb-4">Your feedback has been saved. The business can now publish this as verified proof of work.</p>
|
||||
{% if redirect_url %}
|
||||
<a href="{{ redirect_url }}" class="btn tf-btn tf-btn-primary">Continue to Google review</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h2 class="h3 mb-3">Thanks for the feedback.</h2>
|
||||
<p class="text-secondary-emphasis mb-0">This response stays internal so the business can follow up directly and improve the experience.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="post" class="text-start mt-4">
|
||||
{% csrf_token %}
|
||||
<div class="tf-feedback-grid mb-4">
|
||||
{% for radio in form.experience %}
|
||||
<label class="tf-feedback-option">
|
||||
{{ radio.tag }}
|
||||
<span>{{ radio.choice_label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if form.experience.errors %}<div class="text-danger small mb-3">{{ form.experience.errors|join:', ' }}</div>{% endif %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Optional testimonial</label>
|
||||
{{ form.testimonial }}
|
||||
{% if form.testimonial.errors %}<div class="text-danger small mt-1">{{ form.testimonial.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Submit feedback</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
132
core/templates/core/workspace_settings.html
Normal file
132
core/templates/core/workspace_settings.html
Normal file
@ -0,0 +1,132 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Workspace Settings | TrustForge{% endblock %}
|
||||
{% block meta_description %}Manage your TrustForge workspace, business profile, and team roles.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5">
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<div class="tf-eyebrow">Workspace settings</div>
|
||||
<h1 class="tf-page-title">{{ current_membership.business.name }}</h1>
|
||||
<p class="tf-page-subtitle">Manage the business profile, service territory, review destination, and who has access to this protected trust engine.</p>
|
||||
</div>
|
||||
<a class="btn tf-btn tf-btn-secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-7">
|
||||
<div class="tf-panel h-100">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
|
||||
<div>
|
||||
<h2 class="h4 mb-2">Business profile</h2>
|
||||
<p class="text-secondary-emphasis mb-0">These details anchor onboarding, workspace identity, and public review routing.</p>
|
||||
</div>
|
||||
<div class="tf-inline-stat">
|
||||
<span>Current role</span>
|
||||
<strong>{{ current_membership.get_role_display }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="row g-3">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="update_business">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Business name</label>
|
||||
{{ business_form.name }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Industry</label>
|
||||
{{ business_form.industry }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Primary city</label>
|
||||
{{ business_form.primary_city }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">State</label>
|
||||
{{ business_form.primary_state }}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Google review URL</label>
|
||||
{{ business_form.google_review_url }}
|
||||
</div>
|
||||
<div class="col-12 pt-2">
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary">Save workspace</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel h-100">
|
||||
<h2 class="h4 mb-3">Role access model</h2>
|
||||
<div class="d-grid gap-3">
|
||||
<div class="tf-role-card"><strong>Owner / Admin</strong><span>Manage workspace settings, team access, jobs, and proof publishing.</span></div>
|
||||
<div class="tf-role-card"><strong>Manager</strong><span>Run the job-to-proof workflow and edit proof cards, but not workspace administration.</span></div>
|
||||
<div class="tf-role-card"><strong>Technician</strong><span>Log completed jobs and view pipeline activity inside the assigned business only.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mt-1">
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel h-100">
|
||||
<h2 class="h4 mb-3">Add team member</h2>
|
||||
<p class="text-secondary-emphasis">Attach a user to this workspace. New users can use the forgot-password flow to activate access if they do not have a password yet.</p>
|
||||
<form method="post" class="row g-3">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="invite_member">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">First name</label>
|
||||
{{ invite_form.first_name }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Last name</label>
|
||||
{{ invite_form.last_name }}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Email</label>
|
||||
{{ invite_form.email }}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Role</label>
|
||||
{{ invite_form.role }}
|
||||
</div>
|
||||
<div class="col-12 pt-2">
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary">Add member</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="tf-panel h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h4 mb-0">Workspace team</h2>
|
||||
<span class="tf-card-tag">{{ team_members|length }} seats</span>
|
||||
</div>
|
||||
<div class="d-grid gap-3">
|
||||
{% for membership in team_members %}
|
||||
<div class="tf-team-member">
|
||||
<div>
|
||||
<strong>{{ membership.user.get_full_name|default:membership.user.email }}</strong>
|
||||
<div class="small text-secondary-emphasis">{{ membership.user.email }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="tf-status-pill tf-role-pill">{{ membership.get_role_display }}</span>
|
||||
<div class="small text-secondary-emphasis mt-1">Joined {{ membership.created_at|date:"M j, Y" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="tf-empty-state text-start">
|
||||
<h3 class="h5 mb-2">No team members yet</h3>
|
||||
<p class="text-secondary-emphasis mb-0">Invite admins, managers, and technicians to turn this workspace into a real multi-user SaaS account.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
54
core/templates/registration/login.html
Normal file
54
core/templates/registration/login.html
Normal file
@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login | TrustForge{% endblock %}
|
||||
{% block meta_description %}Sign in to TrustForge to manage jobs, review requests, and proof cards securely.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5 py-lg-6">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-center">
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel tf-panel-centered h-100">
|
||||
<div class="tf-eyebrow">Secure access</div>
|
||||
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
|
||||
<div class="tf-auth-points mt-4">
|
||||
<div class="tf-check-field"><div class="tf-check-row"><span>✓</span><div>Access dashboard, jobs, and proof cards securely</div></div></div>
|
||||
<div class="tf-check-field"><div class="tf-check-row"><span>✓</span><div>Preserve the premium TrustForge workflow without exposing customer data publicly</div></div></div>
|
||||
<div class="tf-check-field"><div class="tf-check-row"><span>✓</span><div>Next step adds business onboarding and role-based team access</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="tf-panel tf-panel-centered tf-auth-card">
|
||||
<div class="tf-card-tag mb-3">Login</div>
|
||||
<h2 class="h3 mb-2">Continue into TrustForge</h2>
|
||||
<p class="text-secondary-emphasis mb-4">Use your work email and password to open the product workspace.</p>
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Work email</label>
|
||||
{{ form.username }}
|
||||
{% if form.username.errors %}<div class="text-danger small mt-1">{{ form.username.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="form-label mb-0">Password</label>
|
||||
<a class="tf-inline-link small" href="{% url 'password_reset' %}">Forgot password?</a>
|
||||
</div>
|
||||
{{ form.password }}
|
||||
{% if form.password.errors %}<div class="text-danger small mt-1">{{ form.password.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
{% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Log in</button>
|
||||
</form>
|
||||
<p class="text-secondary-emphasis small mt-4 mb-0">New to TrustForge? <a class="tf-inline-link" href="{% url 'signup' %}">Create your account</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
21
core/templates/registration/password_reset_complete.html
Normal file
21
core/templates/registration/password_reset_complete.html
Normal file
@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Password Updated | TrustForge{% endblock %}
|
||||
{% block meta_description %}Your TrustForge password has been updated successfully.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5 py-lg-6">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7 col-xl-6">
|
||||
<div class="tf-panel tf-panel-centered text-center tf-auth-card">
|
||||
<div class="tf-eyebrow">All set</div>
|
||||
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||
<p class="tf-page-subtitle mx-auto">{{ auth_page_description }}</p>
|
||||
<a class="btn tf-btn tf-btn-primary mt-4" href="{% url 'login' %}">Log in now</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
45
core/templates/registration/password_reset_confirm.html
Normal file
45
core/templates/registration/password_reset_confirm.html
Normal file
@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Set New Password | TrustForge{% endblock %}
|
||||
{% block meta_description %}Create a new password for your TrustForge account securely.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5 py-lg-6">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7 col-xl-6">
|
||||
<div class="tf-panel tf-panel-centered tf-auth-card">
|
||||
<div class="tf-eyebrow">Create new password</div>
|
||||
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
|
||||
{% if validlink %}
|
||||
<form method="post" novalidate class="mt-4">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New password</label>
|
||||
{{ form.new_password1 }}
|
||||
{% if form.new_password1.errors %}<div class="text-danger small mt-1">{{ form.new_password1.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm new password</label>
|
||||
{{ form.new_password2 }}
|
||||
{% if form.new_password2.errors %}<div class="text-danger small mt-1">{{ form.new_password2.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Update password</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="tf-empty-state text-center mt-4">
|
||||
<h2 class="h4 mb-2">Reset link unavailable</h2>
|
||||
<p class="text-secondary-emphasis mb-0">This reset link is invalid or has already been used. Request a fresh one below.</p>
|
||||
</div>
|
||||
<a class="btn tf-btn tf-btn-primary mt-4" href="{% url 'password_reset' %}">Request a new link</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
25
core/templates/registration/password_reset_done.html
Normal file
25
core/templates/registration/password_reset_done.html
Normal file
@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Check Your Email | TrustForge{% endblock %}
|
||||
{% block meta_description %}Password reset instructions for your TrustForge account have been sent if the email exists.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5 py-lg-6">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7 col-xl-6">
|
||||
<div class="tf-panel tf-panel-centered text-center tf-auth-card">
|
||||
<div class="tf-eyebrow">Email sent</div>
|
||||
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||
<p class="tf-page-subtitle mx-auto">{{ auth_page_description }}</p>
|
||||
<div class="tf-empty-state text-center mt-4">
|
||||
<h2 class="h4 mb-2">Next step</h2>
|
||||
<p class="text-secondary-emphasis mb-0">Open the email from TrustForge and follow the secure link to create a new password.</p>
|
||||
</div>
|
||||
<a class="btn tf-btn tf-btn-secondary mt-4" href="{% url 'login' %}">Return to login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
10
core/templates/registration/password_reset_email.txt
Normal file
10
core/templates/registration/password_reset_email.txt
Normal file
@ -0,0 +1,10 @@
|
||||
Hi,
|
||||
|
||||
We received a request to reset the password for your TrustForge account.
|
||||
|
||||
Use the link below to choose a new password:
|
||||
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
|
||||
|
||||
If you did not request this, you can ignore this email.
|
||||
|
||||
— TrustForge
|
||||
33
core/templates/registration/password_reset_form.html
Normal file
33
core/templates/registration/password_reset_form.html
Normal file
@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Forgot Password | TrustForge{% endblock %}
|
||||
{% block meta_description %}Request a secure password reset link for your TrustForge account.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5 py-lg-6">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7 col-xl-6">
|
||||
<div class="tf-panel tf-panel-centered tf-auth-card">
|
||||
<div class="tf-eyebrow">Password reset</div>
|
||||
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
|
||||
<form method="post" novalidate class="mt-4">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Work email</label>
|
||||
{{ form.email }}
|
||||
{% if form.email.errors %}<div class="text-danger small mt-1">{{ form.email.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary w-100">Send reset link</button>
|
||||
</form>
|
||||
<p class="text-secondary-emphasis small mt-4 mb-0"><a class="tf-inline-link" href="{% url 'login' %}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
1
core/templates/registration/password_reset_subject.txt
Normal file
1
core/templates/registration/password_reset_subject.txt
Normal file
@ -0,0 +1 @@
|
||||
Reset your TrustForge password
|
||||
67
core/templates/registration/signup.html
Normal file
67
core/templates/registration/signup.html
Normal file
@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Get Started | TrustForge{% endblock %}
|
||||
{% block meta_description %}Create your TrustForge account and start turning completed jobs into proof that wins the next customer.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="py-5 py-lg-6">
|
||||
<div class="container">
|
||||
<div class="row g-4 align-items-center">
|
||||
<div class="col-lg-5">
|
||||
<div class="tf-panel tf-panel-centered h-100">
|
||||
<div class="tf-eyebrow">Get started</div>
|
||||
<h1 class="tf-page-title">{{ auth_page_title }}</h1>
|
||||
<p class="tf-page-subtitle">{{ auth_page_description }}</p>
|
||||
<div class="tf-auth-points mt-4">
|
||||
<div class="tf-check-field"><div class="tf-check-row"><span>01</span><div>Create secure email/password access</div></div></div>
|
||||
<div class="tf-check-field"><div class="tf-check-row"><span>02</span><div>Land inside the product instead of a demo-only experience</div></div></div>
|
||||
<div class="tf-check-field"><div class="tf-check-row"><span>03</span><div>Next step will connect your account to a business workspace</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="tf-panel tf-panel-centered tf-auth-card">
|
||||
<div class="tf-card-tag mb-3">Sign up</div>
|
||||
<h2 class="h3 mb-2">Start your TrustForge workspace</h2>
|
||||
<p class="text-secondary-emphasis mb-4">Create the account that will own your dashboard access and future business onboarding.</p>
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger">{{ form.non_field_errors|join:', ' }}</div>
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">First name</label>
|
||||
{{ form.first_name }}
|
||||
{% if form.first_name.errors %}<div class="text-danger small mt-1">{{ form.first_name.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Last name</label>
|
||||
{{ form.last_name }}
|
||||
{% if form.last_name.errors %}<div class="text-danger small mt-1">{{ form.last_name.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Work email</label>
|
||||
{{ form.email }}
|
||||
{% if form.email.errors %}<div class="text-danger small mt-1">{{ form.email.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Password</label>
|
||||
{{ form.password1 }}
|
||||
{% if form.password1.errors %}<div class="text-danger small mt-1">{{ form.password1.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Confirm password</label>
|
||||
{{ form.password2 }}
|
||||
{% if form.password2.errors %}<div class="text-danger small mt-1">{{ form.password2.errors|join:', ' }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn tf-btn tf-btn-primary w-100 mt-4">Create account</button>
|
||||
</form>
|
||||
<p class="text-secondary-emphasis small mt-4 mb-0">Already have an account? <a class="tf-inline-link" href="{% url 'login' %}">Log in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
220
core/tests.py
220
core/tests.py
@ -1,3 +1,221 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
# Create your tests here.
|
||||
from .models import Business, BusinessMembership, Customer, Job, JobMedia, ProofCard, ReviewRequest
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class TrustForgeFlowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username='owner@example.com',
|
||||
email='owner@example.com',
|
||||
password='StrongPass123!',
|
||||
)
|
||||
self.business = Business.objects.create(
|
||||
name='Forge Roofing',
|
||||
slug='forge-roofing',
|
||||
industry='Roofing',
|
||||
primary_city='Austin',
|
||||
primary_state='TX',
|
||||
google_review_url='https://example.com/google-review',
|
||||
)
|
||||
self.membership = BusinessMembership.objects.create(
|
||||
user=self.user,
|
||||
business=self.business,
|
||||
role=BusinessMembership.Role.OWNER,
|
||||
)
|
||||
self.customer = Customer.objects.create(
|
||||
business=self.business,
|
||||
full_name='Jordan Lee',
|
||||
email='jordan@example.com',
|
||||
city='Austin',
|
||||
state='TX',
|
||||
)
|
||||
self.job = Job.objects.create(
|
||||
business=self.business,
|
||||
customer=self.customer,
|
||||
service_type='Roof repair',
|
||||
city='Austin',
|
||||
state='TX',
|
||||
)
|
||||
JobMedia.objects.create(
|
||||
job=self.job,
|
||||
media_type=JobMedia.MediaType.BEFORE,
|
||||
file=SimpleUploadedFile('before-sample.jpg', b'before-image-bytes', content_type='image/jpeg'),
|
||||
)
|
||||
JobMedia.objects.create(
|
||||
job=self.job,
|
||||
media_type=JobMedia.MediaType.AFTER,
|
||||
file=SimpleUploadedFile('after-sample.jpg', b'after-image-bytes', content_type='image/jpeg'),
|
||||
)
|
||||
self.proof_card = ProofCard.objects.create(job=self.job, customer_display_name='Verified homeowner')
|
||||
self.review_request = ReviewRequest.objects.create(job=self.job)
|
||||
|
||||
def test_home_loads(self):
|
||||
response = self.client.get(reverse('home'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'TrustForge')
|
||||
|
||||
def test_dashboard_requires_login(self):
|
||||
response = self.client.get(reverse('dashboard'))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn(reverse('login'), response.url)
|
||||
|
||||
def test_login_with_email_redirects_to_dashboard(self):
|
||||
response = self.client.post(
|
||||
reverse('login'),
|
||||
{'username': 'owner@example.com', 'password': 'StrongPass123!'},
|
||||
)
|
||||
self.assertRedirects(response, reverse('dashboard'))
|
||||
|
||||
def test_logged_in_user_without_membership_redirects_to_onboarding(self):
|
||||
user = User.objects.create_user(
|
||||
username='solo@example.com',
|
||||
email='solo@example.com',
|
||||
password='StrongPass123!',
|
||||
)
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(reverse('dashboard'))
|
||||
self.assertRedirects(response, reverse('business_onboarding'))
|
||||
|
||||
def test_dashboard_only_shows_active_business_data(self):
|
||||
other_business = Business.objects.create(
|
||||
name='Hidden Plumbing',
|
||||
slug='hidden-plumbing',
|
||||
industry='Plumbing',
|
||||
primary_city='Dallas',
|
||||
primary_state='TX',
|
||||
)
|
||||
other_customer = Customer.objects.create(
|
||||
business=other_business,
|
||||
full_name='Taylor Shade',
|
||||
city='Dallas',
|
||||
state='TX',
|
||||
)
|
||||
Job.objects.create(
|
||||
business=other_business,
|
||||
customer=other_customer,
|
||||
service_type='Leak repair',
|
||||
city='Dallas',
|
||||
state='TX',
|
||||
)
|
||||
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse('dashboard'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Forge Roofing proof momentum')
|
||||
self.assertNotContains(response, 'Leak repair')
|
||||
|
||||
def test_technician_cannot_edit_proof_card(self):
|
||||
technician = User.objects.create_user(
|
||||
username='tech@example.com',
|
||||
email='tech@example.com',
|
||||
password='StrongPass123!',
|
||||
)
|
||||
BusinessMembership.objects.create(
|
||||
user=technician,
|
||||
business=self.business,
|
||||
role=BusinessMembership.Role.TECHNICIAN,
|
||||
)
|
||||
self.client.force_login(technician)
|
||||
response = self.client.get(reverse('proof_card_edit', args=[self.proof_card.id]))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_public_review_positive_feedback_publishes_proof(self):
|
||||
response = self.client.post(
|
||||
reverse('review_request', args=[self.review_request.token]),
|
||||
{'experience': 'great', 'testimonial': 'They showed up on time and the roof looks incredible.'},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.proof_card.refresh_from_db()
|
||||
self.assertEqual(self.proof_card.status, 'published')
|
||||
self.assertEqual(self.proof_card.rating, 5)
|
||||
|
||||
|
||||
def test_public_gallery_only_shows_published_cards_for_requested_business(self):
|
||||
self.proof_card.status = ProofCard.Status.PUBLISHED
|
||||
self.proof_card.is_featured = True
|
||||
self.proof_card.testimonial_quote = 'Published proof for Forge Roofing.'
|
||||
self.proof_card.save(update_fields=['status', 'is_featured', 'testimonial_quote'])
|
||||
|
||||
other_business = Business.objects.create(
|
||||
name='Quiet Electric',
|
||||
slug='quiet-electric',
|
||||
industry='Electrical',
|
||||
primary_city='Denver',
|
||||
primary_state='CO',
|
||||
)
|
||||
other_customer = Customer.objects.create(
|
||||
business=other_business,
|
||||
full_name='Morgan Bright',
|
||||
city='Denver',
|
||||
state='CO',
|
||||
)
|
||||
other_job = Job.objects.create(
|
||||
business=other_business,
|
||||
customer=other_customer,
|
||||
service_type='Panel upgrade',
|
||||
city='Denver',
|
||||
state='CO',
|
||||
)
|
||||
ProofCard.objects.create(
|
||||
job=other_job,
|
||||
customer_display_name='Verified homeowner',
|
||||
status=ProofCard.Status.PUBLISHED,
|
||||
testimonial_quote='This should not appear in Forge Roofing gallery.',
|
||||
)
|
||||
|
||||
draft_customer = Customer.objects.create(
|
||||
business=self.business,
|
||||
full_name='Casey Draft',
|
||||
city='Austin',
|
||||
state='TX',
|
||||
)
|
||||
draft_job = Job.objects.create(
|
||||
business=self.business,
|
||||
customer=draft_customer,
|
||||
service_type='Draft-only repair',
|
||||
city='Austin',
|
||||
state='TX',
|
||||
)
|
||||
ProofCard.objects.create(
|
||||
job=draft_job,
|
||||
customer_display_name='Hidden draft',
|
||||
status=ProofCard.Status.DRAFT,
|
||||
testimonial_quote='Draft cards should stay private.',
|
||||
)
|
||||
|
||||
response = self.client.get(reverse('public_proof_gallery', args=[self.business.slug]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Forge Roofing completed work')
|
||||
self.assertContains(response, 'Published proof for Forge Roofing.')
|
||||
self.assertContains(response, self.job.before_media.file.url)
|
||||
self.assertContains(response, self.job.after_media.file.url)
|
||||
self.assertNotContains(response, 'This should not appear in Forge Roofing gallery.')
|
||||
self.assertNotContains(response, 'Draft cards should stay private.')
|
||||
|
||||
|
||||
def test_workspace_proof_detail_renders_uploaded_media(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse('proof_card_detail', args=[self.proof_card.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, self.job.before_media.file.url)
|
||||
self.assertContains(response, self.job.after_media.file.url)
|
||||
|
||||
def test_public_proof_detail_requires_published_status(self):
|
||||
response = self.client.get(reverse('public_proof_detail', args=[self.business.slug, self.proof_card.id]))
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
self.proof_card.status = ProofCard.Status.PUBLISHED
|
||||
self.proof_card.testimonial_quote = 'Proof card is now public.'
|
||||
self.proof_card.save(update_fields=['status', 'testimonial_quote'])
|
||||
|
||||
response = self.client.get(reverse('public_proof_detail', args=[self.business.slug, self.proof_card.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'Proof card is now public.')
|
||||
self.assertContains(response, self.job.before_media.file.url)
|
||||
self.assertContains(response, self.job.after_media.file.url)
|
||||
|
||||
48
core/urls.py
48
core/urls.py
@ -1,7 +1,51 @@
|
||||
from django.contrib.auth.views import LogoutView
|
||||
from django.urls import path
|
||||
|
||||
from .views import home
|
||||
from .views import (
|
||||
TrustForgeLoginView,
|
||||
TrustForgePasswordResetCompleteView,
|
||||
TrustForgePasswordResetConfirmView,
|
||||
TrustForgePasswordResetDoneView,
|
||||
TrustForgePasswordResetView,
|
||||
business_onboarding,
|
||||
dashboard,
|
||||
home,
|
||||
job_create,
|
||||
job_detail,
|
||||
jobs_list,
|
||||
profile_settings,
|
||||
proof_card_detail,
|
||||
proof_card_edit,
|
||||
proof_cards_list,
|
||||
public_proof_detail,
|
||||
public_proof_gallery,
|
||||
review_request_view,
|
||||
signup,
|
||||
switch_workspace,
|
||||
workspace_settings,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", home, name="home"),
|
||||
path('', home, name='home'),
|
||||
path('login/', TrustForgeLoginView.as_view(), name='login'),
|
||||
path('signup/', signup, name='signup'),
|
||||
path('logout/', LogoutView.as_view(), name='logout'),
|
||||
path('forgot-password/', TrustForgePasswordResetView.as_view(), name='password_reset'),
|
||||
path('forgot-password/sent/', TrustForgePasswordResetDoneView.as_view(), name='password_reset_done'),
|
||||
path('reset-password/<uidb64>/<token>/', TrustForgePasswordResetConfirmView.as_view(), name='password_reset_confirm'),
|
||||
path('reset-password/complete/', TrustForgePasswordResetCompleteView.as_view(), name='password_reset_complete'),
|
||||
path('onboarding/business/', business_onboarding, name='business_onboarding'),
|
||||
path('workspace/<int:business_id>/switch/', switch_workspace, name='switch_workspace'),
|
||||
path('workspace/settings/', workspace_settings, name='workspace_settings'),
|
||||
path('profile/', profile_settings, name='profile_settings'),
|
||||
path('dashboard/', dashboard, name='dashboard'),
|
||||
path('jobs/', jobs_list, name='jobs_list'),
|
||||
path('jobs/new/', job_create, name='job_create'),
|
||||
path('jobs/<int:job_id>/', job_detail, name='job_detail'),
|
||||
path('proof-cards/', proof_cards_list, name='proof_cards_list'),
|
||||
path('proof-cards/<int:card_id>/', proof_card_detail, name='proof_card_detail'),
|
||||
path('proof-cards/<int:card_id>/edit/', proof_card_edit, name='proof_card_edit'),
|
||||
path('proof/<slug:slug>/', public_proof_gallery, name='public_proof_gallery'),
|
||||
path('proof/<slug:slug>/<int:card_id>/', public_proof_detail, name='public_proof_detail'),
|
||||
path('reviews/<uuid:token>/', review_request_view, name='review_request'),
|
||||
]
|
||||
|
||||
734
core/views.py
734
core/views.py
@ -1,25 +1,729 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
from functools import wraps
|
||||
|
||||
from django import get_version as django_version
|
||||
from django.shortcuts import render
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model, login
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.views import (
|
||||
LoginView,
|
||||
PasswordResetCompleteView,
|
||||
PasswordResetConfirmView,
|
||||
PasswordResetDoneView,
|
||||
PasswordResetView,
|
||||
)
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.mail import send_mail
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
from .forms import (
|
||||
BusinessOnboardingForm,
|
||||
BusinessSettingsForm,
|
||||
JobIntakeForm,
|
||||
ProfileSettingsForm,
|
||||
ProofCardForm,
|
||||
PublicFeedbackForm,
|
||||
SignUpForm,
|
||||
TeamMemberInviteForm,
|
||||
TrustForgeAuthenticationForm,
|
||||
TrustForgePasswordResetForm,
|
||||
TrustForgeSetPasswordForm,
|
||||
)
|
||||
from .models import Business, BusinessMembership, Customer, Feedback, Job, JobMedia, ProofCard, ReviewRequest
|
||||
|
||||
User = get_user_model()
|
||||
ACTIVE_BUSINESS_SESSION_KEY = 'trustforge_active_business_id'
|
||||
POSITIVE_EXPERIENCES = {Feedback.Experience.GREAT, Feedback.Experience.GOOD}
|
||||
RATING_MAP = {
|
||||
Feedback.Experience.GREAT: 5,
|
||||
Feedback.Experience.GOOD: 4,
|
||||
Feedback.Experience.OKAY: 3,
|
||||
Feedback.Experience.BAD: 2,
|
||||
}
|
||||
|
||||
|
||||
def home(request):
|
||||
"""Render the landing screen with loader and environment details."""
|
||||
host_name = request.get_host().lower()
|
||||
agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic"
|
||||
now = timezone.now()
|
||||
def _theme_context() -> dict:
|
||||
return {
|
||||
'project_name': 'TrustForge',
|
||||
'project_description': (
|
||||
'TrustForge turns completed service jobs into proof cards, testimonials, and conversion assets '
|
||||
'for contractors, HVAC teams, roofers, plumbers, and local service businesses.'
|
||||
),
|
||||
'project_image_url': os.getenv('PROJECT_IMAGE_URL', ''),
|
||||
}
|
||||
|
||||
|
||||
class ThemedAuthContextMixin:
|
||||
auth_page_title = 'TrustForge Account'
|
||||
auth_page_description = 'Secure access to your proof pipeline, review engine, and published proof assets.'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context.update(_theme_context())
|
||||
context.setdefault('auth_page_title', self.auth_page_title)
|
||||
context.setdefault('auth_page_description', self.auth_page_description)
|
||||
return context
|
||||
|
||||
|
||||
class TrustForgeLoginView(ThemedAuthContextMixin, LoginView):
|
||||
template_name = 'registration/login.html'
|
||||
authentication_form = TrustForgeAuthenticationForm
|
||||
redirect_authenticated_user = True
|
||||
auth_page_title = 'Welcome back to your trust engine'
|
||||
auth_page_description = 'Sign in to manage completed jobs, review requests, proof cards, and every asset that helps the next customer say yes.'
|
||||
|
||||
def get_success_url(self):
|
||||
redirect_url = self.get_redirect_url()
|
||||
if redirect_url:
|
||||
return redirect_url
|
||||
if _get_active_membership(self.request) is None:
|
||||
return reverse('business_onboarding')
|
||||
return reverse('dashboard')
|
||||
|
||||
|
||||
class TrustForgePasswordResetView(ThemedAuthContextMixin, PasswordResetView):
|
||||
template_name = 'registration/password_reset_form.html'
|
||||
email_template_name = 'registration/password_reset_email.txt'
|
||||
subject_template_name = 'registration/password_reset_subject.txt'
|
||||
success_url = reverse_lazy('password_reset_done')
|
||||
form_class = TrustForgePasswordResetForm
|
||||
auth_page_title = 'Reset your TrustForge password'
|
||||
auth_page_description = 'Enter your work email and we will send a secure reset link so you can get back into your proof pipeline.'
|
||||
|
||||
|
||||
class TrustForgePasswordResetDoneView(ThemedAuthContextMixin, PasswordResetDoneView):
|
||||
template_name = 'registration/password_reset_done.html'
|
||||
auth_page_title = 'Check your email'
|
||||
auth_page_description = 'If that email is tied to an account, a secure reset link is on its way.'
|
||||
|
||||
|
||||
class TrustForgePasswordResetConfirmView(ThemedAuthContextMixin, PasswordResetConfirmView):
|
||||
template_name = 'registration/password_reset_confirm.html'
|
||||
form_class = TrustForgeSetPasswordForm
|
||||
success_url = reverse_lazy('password_reset_complete')
|
||||
auth_page_title = 'Create a new password'
|
||||
auth_page_description = 'Set a new password for your account and return to the TrustForge dashboard securely.'
|
||||
|
||||
|
||||
class TrustForgePasswordResetCompleteView(ThemedAuthContextMixin, PasswordResetCompleteView):
|
||||
template_name = 'registration/password_reset_complete.html'
|
||||
auth_page_title = 'Password updated'
|
||||
auth_page_description = 'Your password has been changed successfully. You can sign back into TrustForge now.'
|
||||
|
||||
|
||||
def _get_memberships_queryset(user):
|
||||
return BusinessMembership.objects.select_related('business').filter(user=user, business__is_active=True)
|
||||
|
||||
|
||||
def _get_user_memberships(request: HttpRequest) -> list[BusinessMembership]:
|
||||
if not request.user.is_authenticated:
|
||||
return []
|
||||
cached = getattr(request, '_trustforge_memberships', None)
|
||||
if cached is None:
|
||||
cached = list(_get_memberships_queryset(request.user))
|
||||
request._trustforge_memberships = cached
|
||||
return cached
|
||||
|
||||
|
||||
def _get_active_membership(request: HttpRequest) -> BusinessMembership | None:
|
||||
if not request.user.is_authenticated:
|
||||
return None
|
||||
cached = getattr(request, '_trustforge_active_membership', None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
memberships = _get_user_memberships(request)
|
||||
active_business_id = request.session.get(ACTIVE_BUSINESS_SESSION_KEY)
|
||||
membership = next((item for item in memberships if item.business_id == active_business_id), None)
|
||||
if membership is None and memberships:
|
||||
membership = memberships[0]
|
||||
request._trustforge_active_membership = membership
|
||||
return membership
|
||||
|
||||
|
||||
def _set_active_membership(request: HttpRequest, business_id: int) -> None:
|
||||
request.session[ACTIVE_BUSINESS_SESSION_KEY] = business_id
|
||||
request._trustforge_active_membership = None
|
||||
|
||||
|
||||
def _generate_unique_business_slug(name: str) -> str:
|
||||
base_slug = slugify(name)[:45] or 'business'
|
||||
candidate = base_slug
|
||||
counter = 2
|
||||
while Business.objects.filter(slug=candidate).exists():
|
||||
candidate = f'{base_slug}-{counter}'[:55]
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def business_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapped(request: HttpRequest, *args, **kwargs):
|
||||
if _get_active_membership(request) is None:
|
||||
messages.info(request, 'Create or join a business workspace to unlock your protected TrustForge pipeline.')
|
||||
return redirect('business_onboarding')
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def membership_role_required(*allowed_roles: str):
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapped(request: HttpRequest, *args, **kwargs):
|
||||
membership = _get_active_membership(request)
|
||||
if membership is None:
|
||||
messages.info(request, 'Create or join a business workspace to continue.')
|
||||
return redirect('business_onboarding')
|
||||
if membership.role not in allowed_roles:
|
||||
raise PermissionDenied('Your role does not allow this action in the current workspace.')
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _build_review_link(request: HttpRequest, review_request: ReviewRequest) -> str:
|
||||
return request.build_absolute_uri(reverse('review_request', args=[str(review_request.token)]))
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def _create_review_request(request: HttpRequest, job: Job, channel: str) -> ReviewRequest:
|
||||
review_request, created = ReviewRequest.objects.get_or_create(
|
||||
job=job,
|
||||
defaults={
|
||||
'channel': channel,
|
||||
'status': ReviewRequest.Status.SENT,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
review_request.delivery_note = 'Share this link manually from the field app.'
|
||||
if channel == ReviewRequest.Channel.EMAIL and job.customer.email:
|
||||
review_link = _build_review_link(request, review_request)
|
||||
email_sent = send_mail(
|
||||
subject=f'How was your {job.service_type.lower()} experience?',
|
||||
message=(
|
||||
f'Hi {job.customer.full_name}\n\n'
|
||||
f'Thanks for choosing {job.business.name}. Please share your feedback here: {review_link}\n\n'
|
||||
'Your response helps us build verified proof of work for future customers.'
|
||||
),
|
||||
from_email=None,
|
||||
recipient_list=[job.customer.email],
|
||||
fail_silently=True,
|
||||
)
|
||||
review_request.delivery_note = 'Email sent automatically.' if email_sent else 'Email backend unavailable — copy link manually.'
|
||||
review_request.status = ReviewRequest.Status.SENT
|
||||
review_request.sent_at = timezone.now()
|
||||
review_request.save()
|
||||
job.status = Job.Status.REVIEW_REQUESTED
|
||||
job.save(update_fields=['status'])
|
||||
return review_request
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def signup(request: HttpRequest) -> HttpResponse:
|
||||
if request.user.is_authenticated:
|
||||
return redirect('dashboard' if _get_active_membership(request) else 'business_onboarding')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = SignUpForm(request.POST)
|
||||
if form.is_valid():
|
||||
user = form.save()
|
||||
login(request, user)
|
||||
messages.success(request, 'Your TrustForge account is ready. Now let’s create your first business workspace.')
|
||||
return redirect('business_onboarding')
|
||||
else:
|
||||
form = SignUpForm()
|
||||
|
||||
context = {
|
||||
"project_name": "New Style",
|
||||
"agent_brand": agent_brand,
|
||||
"django_version": django_version(),
|
||||
"python_version": platform.python_version(),
|
||||
"current_time": now,
|
||||
"host_name": host_name,
|
||||
"project_description": os.getenv("PROJECT_DESCRIPTION", ""),
|
||||
"project_image_url": os.getenv("PROJECT_IMAGE_URL", ""),
|
||||
**_theme_context(),
|
||||
'auth_page_title': 'Create your TrustForge account',
|
||||
'auth_page_description': 'Start with secure account access, then connect your business workspace, team roles, and protected proof pipeline.',
|
||||
'form': form,
|
||||
}
|
||||
return render(request, "core/index.html", context)
|
||||
return render(request, 'registration/signup.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@transaction.atomic
|
||||
def business_onboarding(request: HttpRequest) -> HttpResponse:
|
||||
current_membership = _get_active_membership(request)
|
||||
if current_membership is not None:
|
||||
return redirect('dashboard')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = BusinessOnboardingForm(request.POST)
|
||||
if form.is_valid():
|
||||
business = form.save(commit=False)
|
||||
business.slug = _generate_unique_business_slug(business.name)
|
||||
business.save()
|
||||
membership = BusinessMembership.objects.create(
|
||||
business=business,
|
||||
user=request.user,
|
||||
role=BusinessMembership.Role.OWNER,
|
||||
)
|
||||
_set_active_membership(request, membership.business_id)
|
||||
messages.success(request, 'Workspace created. Your jobs, proof cards, and reviews are now scoped to this business.')
|
||||
return redirect('dashboard')
|
||||
else:
|
||||
form = BusinessOnboardingForm()
|
||||
|
||||
context = {
|
||||
**_theme_context(),
|
||||
'form': form,
|
||||
}
|
||||
return render(request, 'core/business_onboarding.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@transaction.atomic
|
||||
def switch_workspace(request: HttpRequest, business_id: int) -> HttpResponse:
|
||||
membership = get_object_or_404(_get_memberships_queryset(request.user), business_id=business_id)
|
||||
_set_active_membership(request, membership.business_id)
|
||||
messages.success(request, f'Workspace switched to {membership.business.name}.')
|
||||
next_url = request.POST.get('next') or reverse('dashboard')
|
||||
return redirect(next_url)
|
||||
|
||||
|
||||
@login_required
|
||||
@transaction.atomic
|
||||
def profile_settings(request: HttpRequest) -> HttpResponse:
|
||||
current_membership = _get_active_membership(request)
|
||||
memberships = _get_user_memberships(request)
|
||||
|
||||
if request.method == 'POST':
|
||||
form = ProfileSettingsForm(request.POST, instance=request.user)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, 'Profile settings updated.')
|
||||
return redirect('profile_settings')
|
||||
else:
|
||||
form = ProfileSettingsForm(instance=request.user)
|
||||
|
||||
context = {
|
||||
**_theme_context(),
|
||||
'form': form,
|
||||
'current_membership': current_membership,
|
||||
'memberships': memberships,
|
||||
}
|
||||
return render(request, 'core/profile_settings.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@membership_role_required(BusinessMembership.Role.OWNER, BusinessMembership.Role.ADMIN)
|
||||
@transaction.atomic
|
||||
def workspace_settings(request: HttpRequest) -> HttpResponse:
|
||||
current_membership = _get_active_membership(request)
|
||||
business = current_membership.business
|
||||
team_members = BusinessMembership.objects.select_related('user').filter(business=business).order_by('created_at', 'id')
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.POST.get('action')
|
||||
if action == 'update_business':
|
||||
business_form = BusinessSettingsForm(request.POST, instance=business)
|
||||
invite_form = TeamMemberInviteForm()
|
||||
if business_form.is_valid():
|
||||
business_form.save()
|
||||
messages.success(request, 'Workspace settings updated.')
|
||||
return redirect('workspace_settings')
|
||||
elif action == 'invite_member':
|
||||
business_form = BusinessSettingsForm(instance=business)
|
||||
invite_form = TeamMemberInviteForm(request.POST)
|
||||
if invite_form.is_valid():
|
||||
email = invite_form.cleaned_data['email']
|
||||
user, created = User.objects.get_or_create(
|
||||
email=email,
|
||||
defaults={
|
||||
'username': email,
|
||||
'email': email,
|
||||
'first_name': invite_form.cleaned_data.get('first_name', '').strip(),
|
||||
'last_name': invite_form.cleaned_data.get('last_name', '').strip(),
|
||||
},
|
||||
)
|
||||
if created:
|
||||
user.set_unusable_password()
|
||||
user.save(update_fields=['password'])
|
||||
else:
|
||||
updated_fields = []
|
||||
first_name = invite_form.cleaned_data.get('first_name', '').strip()
|
||||
last_name = invite_form.cleaned_data.get('last_name', '').strip()
|
||||
if first_name and not user.first_name:
|
||||
user.first_name = first_name
|
||||
updated_fields.append('first_name')
|
||||
if last_name and not user.last_name:
|
||||
user.last_name = last_name
|
||||
updated_fields.append('last_name')
|
||||
if updated_fields:
|
||||
user.save(update_fields=updated_fields)
|
||||
|
||||
membership, membership_created = BusinessMembership.objects.update_or_create(
|
||||
business=business,
|
||||
user=user,
|
||||
defaults={'role': invite_form.cleaned_data['role']},
|
||||
)
|
||||
if membership_created or created:
|
||||
messages.success(request, 'Team member added. If this is a brand-new user, they can use “Forgot password” to set access.')
|
||||
else:
|
||||
messages.success(request, 'Team member role updated for this workspace.')
|
||||
return redirect('workspace_settings')
|
||||
else:
|
||||
business_form = BusinessSettingsForm(instance=business)
|
||||
invite_form = TeamMemberInviteForm()
|
||||
else:
|
||||
business_form = BusinessSettingsForm(instance=business)
|
||||
invite_form = TeamMemberInviteForm()
|
||||
|
||||
context = {
|
||||
**_theme_context(),
|
||||
'business_form': business_form,
|
||||
'invite_form': invite_form,
|
||||
'current_membership': current_membership,
|
||||
'team_members': team_members,
|
||||
}
|
||||
return render(request, 'core/workspace_settings.html', context)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def home(request: HttpRequest) -> HttpResponse:
|
||||
businesses = Business.objects.count()
|
||||
stats = Job.objects.aggregate(
|
||||
completed_jobs=Count('id'),
|
||||
review_requests=Count('review_request'),
|
||||
proof_cards=Count('proof_card'),
|
||||
published_proof=Count('proof_card', filter=Q(proof_card__status=ProofCard.Status.PUBLISHED)),
|
||||
)
|
||||
featured_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
|
||||
is_featured=True,
|
||||
status=ProofCard.Status.PUBLISHED,
|
||||
job__business__is_active=True,
|
||||
)[:3]
|
||||
recent_jobs = Job.objects.select_related('customer', 'business').prefetch_related('media')[:4]
|
||||
|
||||
context = {
|
||||
**_theme_context(),
|
||||
'django_version': django_version(),
|
||||
'python_version': platform.python_version(),
|
||||
'current_time': timezone.now(),
|
||||
'business_count': businesses,
|
||||
'stats': stats,
|
||||
'featured_proofs': featured_proofs,
|
||||
'recent_jobs': recent_jobs,
|
||||
}
|
||||
return render(request, 'core/index.html', context)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def public_proof_gallery(request: HttpRequest, slug: str) -> HttpResponse:
|
||||
business = get_object_or_404(Business, slug=slug, is_active=True)
|
||||
proof_cards = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
|
||||
job__business=business,
|
||||
status=ProofCard.Status.PUBLISHED,
|
||||
)
|
||||
featured_proofs = proof_cards.filter(is_featured=True)[:3]
|
||||
|
||||
context = {
|
||||
**_theme_context(),
|
||||
'business': business,
|
||||
'proof_cards': proof_cards,
|
||||
'featured_proofs': featured_proofs,
|
||||
}
|
||||
return render(request, 'core/public_proof_gallery.html', context)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def public_proof_detail(request: HttpRequest, slug: str, card_id: int) -> HttpResponse:
|
||||
proof_card = get_object_or_404(
|
||||
ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'),
|
||||
id=card_id,
|
||||
job__business__slug=slug,
|
||||
job__business__is_active=True,
|
||||
status=ProofCard.Status.PUBLISHED,
|
||||
)
|
||||
related_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
|
||||
job__business=proof_card.job.business,
|
||||
status=ProofCard.Status.PUBLISHED,
|
||||
).exclude(id=proof_card.id)[:3]
|
||||
|
||||
context = {
|
||||
**_theme_context(),
|
||||
'business': proof_card.job.business,
|
||||
'proof_card': proof_card,
|
||||
'related_proofs': related_proofs,
|
||||
}
|
||||
return render(request, 'core/public_proof_detail.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@business_required
|
||||
@transaction.atomic
|
||||
def dashboard(request: HttpRequest) -> HttpResponse:
|
||||
current_membership = _get_active_membership(request)
|
||||
business = current_membership.business
|
||||
jobs = Job.objects.filter(business=business)
|
||||
stats = jobs.aggregate(
|
||||
completed_jobs=Count('id'),
|
||||
review_requests=Count('review_request'),
|
||||
proof_cards=Count('proof_card'),
|
||||
published_cards=Count('proof_card', filter=Q(proof_card__status=ProofCard.Status.PUBLISHED)),
|
||||
)
|
||||
feedback_qs = Feedback.objects.filter(review_request__job__business=business)
|
||||
positive_feedback = feedback_qs.filter(experience__in=POSITIVE_EXPERIENCES).count()
|
||||
total_feedback = feedback_qs.count()
|
||||
conversion_rate = round((positive_feedback / total_feedback) * 100, 1) if total_feedback else 0
|
||||
|
||||
recent_jobs = jobs.select_related('customer', 'business').prefetch_related('media')[:5]
|
||||
recent_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(job__business=business)[:4]
|
||||
|
||||
context = {
|
||||
**_theme_context(),
|
||||
'current_membership': current_membership,
|
||||
'stats': stats,
|
||||
'conversion_rate': conversion_rate,
|
||||
'recent_jobs': recent_jobs,
|
||||
'recent_proofs': recent_proofs,
|
||||
}
|
||||
return render(request, 'core/dashboard.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@business_required
|
||||
@transaction.atomic
|
||||
def jobs_list(request: HttpRequest) -> HttpResponse:
|
||||
current_membership = _get_active_membership(request)
|
||||
jobs = Job.objects.select_related('customer', 'business').prefetch_related('media').filter(business=current_membership.business)
|
||||
context = {**_theme_context(), 'jobs': jobs, 'current_membership': current_membership}
|
||||
return render(request, 'core/jobs_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@business_required
|
||||
@transaction.atomic
|
||||
def job_create(request: HttpRequest) -> HttpResponse:
|
||||
current_membership = _get_active_membership(request)
|
||||
business = current_membership.business
|
||||
|
||||
if request.method == 'POST':
|
||||
form = JobIntakeForm(request.POST, request.FILES, business=business)
|
||||
if form.is_valid():
|
||||
customer = Customer.objects.create(
|
||||
business=business,
|
||||
full_name=form.cleaned_data['customer_name'],
|
||||
email=form.cleaned_data['customer_email'],
|
||||
phone=form.cleaned_data['customer_phone'],
|
||||
city=form.cleaned_data['customer_city'],
|
||||
state=form.cleaned_data['customer_state'],
|
||||
)
|
||||
job = Job.objects.create(
|
||||
business=business,
|
||||
customer=customer,
|
||||
service_type=form.cleaned_data['service_type'],
|
||||
description=form.cleaned_data['description'],
|
||||
technician_name=form.cleaned_data['technician_name'],
|
||||
city=form.cleaned_data['customer_city'],
|
||||
state=form.cleaned_data['customer_state'],
|
||||
completed_at=form.cleaned_data['completion_date'],
|
||||
project_value=form.cleaned_data['project_value'],
|
||||
status=Job.Status.COMPLETED,
|
||||
)
|
||||
for media_type, upload in (
|
||||
(JobMedia.MediaType.BEFORE, form.cleaned_data.get('before_photo')),
|
||||
(JobMedia.MediaType.AFTER, form.cleaned_data.get('after_photo')),
|
||||
):
|
||||
if upload:
|
||||
JobMedia.objects.create(job=job, media_type=media_type, file=upload)
|
||||
|
||||
display_name = 'Verified homeowner' if form.cleaned_data['anonymize_customer'] else customer.full_name
|
||||
ProofCard.objects.create(
|
||||
job=job,
|
||||
customer_display_name=display_name,
|
||||
is_anonymized=form.cleaned_data['anonymize_customer'],
|
||||
attached_widget_label='Homepage proof gallery',
|
||||
attached_pages='Homepage, Service pages',
|
||||
status=ProofCard.Status.DRAFT,
|
||||
)
|
||||
|
||||
if form.cleaned_data['send_review_request']:
|
||||
_create_review_request(request, job, form.cleaned_data['review_channel'])
|
||||
|
||||
messages.success(request, 'Job logged inside your workspace. Proof card drafted and ready for review workflow.')
|
||||
return redirect('job_detail', job_id=job.id)
|
||||
else:
|
||||
form = JobIntakeForm(
|
||||
business=business,
|
||||
initial={
|
||||
'business': business,
|
||||
'customer_city': business.primary_city,
|
||||
'customer_state': business.primary_state,
|
||||
'technician_name': request.user.get_full_name(),
|
||||
},
|
||||
)
|
||||
|
||||
context = {**_theme_context(), 'form': form, 'current_membership': current_membership}
|
||||
return render(request, 'core/job_form.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@business_required
|
||||
@transaction.atomic
|
||||
def job_detail(request: HttpRequest, job_id: int) -> HttpResponse:
|
||||
current_membership = _get_active_membership(request)
|
||||
job = get_object_or_404(
|
||||
Job.objects.select_related('customer', 'business', 'proof_card', 'review_request').prefetch_related('media'),
|
||||
id=job_id,
|
||||
business=current_membership.business,
|
||||
)
|
||||
if request.method == 'POST' and request.POST.get('action') == 'send_review_request':
|
||||
channel = request.POST.get('channel', ReviewRequest.Channel.EMAIL)
|
||||
review_request = _create_review_request(request, job, channel)
|
||||
messages.success(request, f'Review request sent. Share link: {_build_review_link(request, review_request)}')
|
||||
return redirect('job_detail', job_id=job.id)
|
||||
|
||||
context = {**_theme_context(), 'job': job, 'current_membership': current_membership}
|
||||
return render(request, 'core/job_detail.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@business_required
|
||||
@transaction.atomic
|
||||
def proof_cards_list(request: HttpRequest) -> HttpResponse:
|
||||
current_membership = _get_active_membership(request)
|
||||
proof_cards = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(
|
||||
job__business=current_membership.business
|
||||
)
|
||||
context = {**_theme_context(), 'proof_cards': proof_cards, 'current_membership': current_membership}
|
||||
return render(request, 'core/proof_cards_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@business_required
|
||||
@transaction.atomic
|
||||
def proof_card_detail(request: HttpRequest, card_id: int) -> HttpResponse:
|
||||
current_membership = _get_active_membership(request)
|
||||
proof_card = get_object_or_404(
|
||||
ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'),
|
||||
id=card_id,
|
||||
job__business=current_membership.business,
|
||||
)
|
||||
if request.method == 'POST':
|
||||
if not current_membership.can_manage_proof:
|
||||
raise PermissionDenied('Your role does not allow proof publishing controls.')
|
||||
action = request.POST.get('action')
|
||||
if action == 'publish':
|
||||
proof_card.status = ProofCard.Status.PUBLISHED
|
||||
proof_card.published_at = timezone.now()
|
||||
proof_card.save(update_fields=['status', 'published_at', 'updated_at'])
|
||||
proof_card.job.status = Job.Status.PROOF_READY
|
||||
proof_card.job.save(update_fields=['status'])
|
||||
messages.success(request, 'Proof card published to the trust gallery.')
|
||||
elif action == 'hide':
|
||||
proof_card.status = ProofCard.Status.HIDDEN
|
||||
proof_card.save(update_fields=['status', 'updated_at'])
|
||||
messages.success(request, 'Proof card hidden from public display.')
|
||||
elif action == 'toggle_featured':
|
||||
proof_card.is_featured = not proof_card.is_featured
|
||||
proof_card.save(update_fields=['is_featured', 'updated_at'])
|
||||
messages.success(request, 'Featured flag updated.')
|
||||
return redirect('proof_card_detail', card_id=proof_card.id)
|
||||
|
||||
context = {**_theme_context(), 'proof_card': proof_card, 'current_membership': current_membership}
|
||||
return render(request, 'core/proof_card_detail.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
@membership_role_required(BusinessMembership.Role.OWNER, BusinessMembership.Role.ADMIN, BusinessMembership.Role.MANAGER)
|
||||
@transaction.atomic
|
||||
def proof_card_edit(request: HttpRequest, card_id: int) -> HttpResponse:
|
||||
current_membership = _get_active_membership(request)
|
||||
proof_card = get_object_or_404(
|
||||
ProofCard.objects.select_related('job__customer', 'job__business'),
|
||||
id=card_id,
|
||||
job__business=current_membership.business,
|
||||
)
|
||||
if request.method == 'POST':
|
||||
form = ProofCardForm(request.POST, instance=proof_card)
|
||||
if form.is_valid():
|
||||
proof_card = form.save(commit=False)
|
||||
if proof_card.status == ProofCard.Status.PUBLISHED and not proof_card.published_at:
|
||||
proof_card.published_at = timezone.now()
|
||||
proof_card.save()
|
||||
messages.success(request, 'Proof card updated.')
|
||||
return redirect('proof_card_detail', card_id=proof_card.id)
|
||||
else:
|
||||
form = ProofCardForm(instance=proof_card)
|
||||
|
||||
context = {**_theme_context(), 'form': form, 'proof_card': proof_card, 'current_membership': current_membership}
|
||||
return render(request, 'core/proof_card_form.html', context)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def review_request_view(request: HttpRequest, token: str) -> HttpResponse:
|
||||
review_request = get_object_or_404(
|
||||
ReviewRequest.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'),
|
||||
token=token,
|
||||
)
|
||||
proof_card = review_request.job.proof_card
|
||||
if review_request.status == ReviewRequest.Status.SENT:
|
||||
review_request.status = ReviewRequest.Status.VIEWED
|
||||
review_request.last_opened_at = timezone.now()
|
||||
review_request.save(update_fields=['status', 'last_opened_at'])
|
||||
|
||||
submitted = False
|
||||
positive = False
|
||||
redirect_url = review_request.job.business.google_review_url
|
||||
|
||||
if request.method == 'POST':
|
||||
form = PublicFeedbackForm(request.POST)
|
||||
if form.is_valid():
|
||||
experience = form.cleaned_data['experience']
|
||||
testimonial = form.cleaned_data['testimonial'].strip()
|
||||
positive = experience in POSITIVE_EXPERIENCES
|
||||
feedback, _ = Feedback.objects.update_or_create(
|
||||
review_request=review_request,
|
||||
defaults={
|
||||
'experience': experience,
|
||||
'rating': RATING_MAP[experience],
|
||||
'testimonial': testimonial,
|
||||
'follow_up_required': not positive,
|
||||
'is_public_approved': positive,
|
||||
},
|
||||
)
|
||||
review_request.status = ReviewRequest.Status.RESPONDED
|
||||
review_request.reviewed_at = timezone.now()
|
||||
review_request.save(update_fields=['status', 'reviewed_at'])
|
||||
|
||||
proof_card.rating = feedback.rating
|
||||
proof_card.testimonial_quote = testimonial
|
||||
if positive:
|
||||
proof_card.status = ProofCard.Status.PUBLISHED
|
||||
proof_card.published_at = timezone.now()
|
||||
else:
|
||||
proof_card.status = ProofCard.Status.DRAFT
|
||||
proof_card.save(update_fields=['rating', 'testimonial_quote', 'status', 'published_at', 'updated_at'])
|
||||
review_request.job.status = Job.Status.PROOF_READY if positive else Job.Status.REVIEW_REQUESTED
|
||||
review_request.job.save(update_fields=['status'])
|
||||
submitted = True
|
||||
form = PublicFeedbackForm()
|
||||
else:
|
||||
form = PublicFeedbackForm()
|
||||
|
||||
context = {
|
||||
**_theme_context(),
|
||||
'review_request': review_request,
|
||||
'job': review_request.job,
|
||||
'proof_card': proof_card,
|
||||
'form': form,
|
||||
'submitted': submitted,
|
||||
'positive': positive,
|
||||
'redirect_url': redirect_url,
|
||||
}
|
||||
return render(request, 'core/review_request.html', context)
|
||||
|
||||
@ -1,4 +1,840 @@
|
||||
/* Custom styles for the application */
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
/* TrustForge design system */
|
||||
:root {
|
||||
--tf-bg: #f4f7f6;
|
||||
--tf-surface: rgba(255, 255, 255, 0.82);
|
||||
--tf-surface-strong: #ffffff;
|
||||
--tf-surface-dark: #0f172a;
|
||||
--tf-border: rgba(15, 23, 42, 0.08);
|
||||
--tf-primary: #0f766e;
|
||||
--tf-primary-deep: #115e59;
|
||||
--tf-secondary: #1e293b;
|
||||
--tf-accent: #f97316;
|
||||
--tf-accent-soft: #fff1e8;
|
||||
--tf-success: #15803d;
|
||||
--tf-text: #0f172a;
|
||||
--tf-muted: #64748b;
|
||||
--tf-shadow: 0 20px 60px rgba(15, 23, 42, 0.10);
|
||||
--tf-shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||
--tf-radius-xl: 28px;
|
||||
--tf-radius-lg: 22px;
|
||||
--tf-radius-md: 16px;
|
||||
--tf-spacing: 1.5rem;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body.trustforge-body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: var(--tf-text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 35%),
|
||||
radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #fbfcfb 0%, var(--tf-bg) 100%);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, .navbar-brand, .tf-display, .tf-section-title, .tf-page-title {
|
||||
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tf-background-glow {
|
||||
position: fixed;
|
||||
border-radius: 999px;
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tf-background-glow-1 {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
top: 10%;
|
||||
left: -4%;
|
||||
}
|
||||
|
||||
.tf-background-glow-2 {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
background: rgba(249, 115, 22, 0.10);
|
||||
right: -6%;
|
||||
top: 12%;
|
||||
}
|
||||
|
||||
main, .tf-site-header, .tf-footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.py-lg-6 {
|
||||
padding-top: 5rem !important;
|
||||
padding-bottom: 5rem !important;
|
||||
}
|
||||
|
||||
.tf-navbar {
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
backdrop-filter: blur(18px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.68);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.tf-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--tf-secondary);
|
||||
}
|
||||
|
||||
.tf-brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--tf-primary), #2dd4bf);
|
||||
color: white;
|
||||
box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
|
||||
}
|
||||
|
||||
.tf-nav-toggle {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tf-navbar .nav-link {
|
||||
color: var(--tf-muted);
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tf-navbar .nav-link:hover,
|
||||
.tf-navbar .nav-link:focus {
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: var(--tf-primary-deep);
|
||||
}
|
||||
|
||||
.tf-btn {
|
||||
border-radius: 999px;
|
||||
padding: 0.85rem 1.35rem;
|
||||
font-weight: 700;
|
||||
border: 0;
|
||||
box-shadow: var(--tf-shadow-soft);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.tf-btn:hover,
|
||||
.tf-btn:focus {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.tf-btn-primary {
|
||||
background: linear-gradient(135deg, var(--tf-primary), #14b8a6);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tf-btn-primary:hover,
|
||||
.tf-btn-primary:focus {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tf-btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--tf-secondary);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.tf-alert {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 118, 110, 0.12);
|
||||
background: rgba(236, 253, 245, 0.88);
|
||||
color: var(--tf-primary-deep);
|
||||
box-shadow: var(--tf-shadow-soft);
|
||||
}
|
||||
|
||||
.tf-hero-section {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tf-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: var(--tf-primary-deep);
|
||||
padding: 0.5rem 0.95rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.tf-eyebrow-light {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.tf-display {
|
||||
font-size: clamp(2.75rem, 7vw, 5.2rem);
|
||||
line-height: 0.98;
|
||||
max-width: 13ch;
|
||||
}
|
||||
|
||||
.tf-lead,
|
||||
.tf-page-subtitle {
|
||||
color: var(--tf-muted);
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.75;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.tf-page-title {
|
||||
font-size: clamp(2rem, 4vw, 3.3rem);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tf-section-title {
|
||||
font-size: clamp(1.75rem, 3.2vw, 2.6rem);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tf-stat-row span,
|
||||
.tf-metric-card span {
|
||||
display: block;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
color: var(--tf-secondary);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tf-stat-chip,
|
||||
.tf-metric-card {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: var(--tf-radius-md);
|
||||
padding: 1.1rem 1rem;
|
||||
box-shadow: var(--tf-shadow-soft);
|
||||
color: var(--tf-muted);
|
||||
}
|
||||
|
||||
.tf-device-card,
|
||||
.tf-panel,
|
||||
.tf-proof-card,
|
||||
.tf-empty-state {
|
||||
background: var(--tf-surface);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.75);
|
||||
border-radius: var(--tf-radius-xl);
|
||||
box-shadow: var(--tf-shadow);
|
||||
}
|
||||
|
||||
.tf-device-card {
|
||||
padding: 1.25rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tf-device-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.18), transparent);
|
||||
border-radius: 36px;
|
||||
right: -30px;
|
||||
bottom: -40px;
|
||||
transform: rotate(18deg);
|
||||
}
|
||||
|
||||
.tf-device-header {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tf-device-header span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.tf-device-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tf-mini-step,
|
||||
.tf-activity-row,
|
||||
.tf-proof-mini {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tf-mini-step.active {
|
||||
color: var(--tf-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-proof-preview,
|
||||
.tf-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.tf-panel-dark {
|
||||
background: linear-gradient(135deg, #0f172a, #1e293b);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tf-panel-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 118, 110, 0.10);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.tf-proof-media-grid,
|
||||
.tf-proof-media-grid-large,
|
||||
.tf-proof-media-grid-static {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.tf-proof-media-grid-static {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tf-photo-slot {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
min-height: 170px;
|
||||
border-radius: 20px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0)),
|
||||
linear-gradient(135deg, rgba(15, 23, 42, 0.88), rgba(15, 118, 110, 0.82));
|
||||
color: rgba(255,255,255,0.92);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: start;
|
||||
padding: 1rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.10);
|
||||
}
|
||||
|
||||
.tf-photo-slot::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0.56));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tf-photo-slot-after {
|
||||
background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86));
|
||||
}
|
||||
|
||||
.tf-photo-slot-has-media {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
}
|
||||
|
||||
.tf-photo-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tf-photo-label {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.tf-empty-proof {
|
||||
border-radius: 22px;
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tf-proof-card-link {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tf-proof-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.tf-proof-card-body {
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.tf-card-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: var(--tf-primary-deep);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-badge-verified,
|
||||
.tf-rating-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.42rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tf-badge-verified {
|
||||
background: rgba(21, 128, 61, 0.10);
|
||||
color: var(--tf-success);
|
||||
}
|
||||
|
||||
.tf-rating-pill {
|
||||
background: var(--tf-accent-soft);
|
||||
color: var(--tf-accent);
|
||||
}
|
||||
|
||||
.tf-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tf-status-completed,
|
||||
.tf-status-draft {
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
color: var(--tf-secondary);
|
||||
}
|
||||
|
||||
.tf-status-review_requested,
|
||||
.tf-status-sent,
|
||||
.tf-status-viewed {
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
color: var(--tf-accent);
|
||||
}
|
||||
|
||||
.tf-status-proof_ready,
|
||||
.tf-status-published,
|
||||
.tf-status-responded {
|
||||
background: rgba(21, 128, 61, 0.12);
|
||||
color: var(--tf-success);
|
||||
}
|
||||
|
||||
.tf-status-hidden {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: var(--tf-muted);
|
||||
}
|
||||
|
||||
.tf-inline-link {
|
||||
color: var(--tf-primary-deep);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-inline-link:hover,
|
||||
.tf-inline-link:focus {
|
||||
color: var(--tf-primary);
|
||||
}
|
||||
|
||||
.tf-activity-row-soft {
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.tf-activity-link,
|
||||
.tf-proof-mini {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tf-proof-mini {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tf-table thead th {
|
||||
border-bottom-width: 0;
|
||||
color: var(--tf-muted);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1.1rem 1.25rem;
|
||||
}
|
||||
|
||||
.tf-table tbody td {
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-color: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tf-detail-box {
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tf-detail-box span {
|
||||
display: block;
|
||||
color: var(--tf-muted);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tf-detail-box strong {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tf-check-row {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tf-check-row span {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: var(--tf-primary-deep);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tf-check-field {
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tf-sticky-panel {
|
||||
top: 6rem;
|
||||
}
|
||||
|
||||
.tf-feedback-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tf-feedback-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 96px;
|
||||
border-radius: 22px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tf-feedback-option input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tf-feedback-option:has(input:checked) {
|
||||
border-color: rgba(15, 118, 110, 0.55);
|
||||
background: rgba(236, 253, 245, 0.95);
|
||||
color: var(--tf-primary-deep);
|
||||
box-shadow: 0 12px 28px rgba(15, 118, 110, 0.14);
|
||||
}
|
||||
|
||||
.tf-testimonial {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
color: var(--tf-secondary);
|
||||
border-left: 4px solid rgba(15, 118, 110, 0.22);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.tf-panel-centered {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.tf-footer {
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tf-footer-brand {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
min-height: 50px;
|
||||
border-radius: 16px;
|
||||
border-color: rgba(15, 23, 42, 0.10);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus,
|
||||
.form-check-input:focus,
|
||||
.btn:focus {
|
||||
border-color: rgba(15, 118, 110, 0.4);
|
||||
box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.tf-display {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tf-feedback-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.tf-device-card,
|
||||
.tf-panel,
|
||||
.tf-proof-card,
|
||||
.tf-empty-state {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.tf-proof-media-grid,
|
||||
.tf-proof-media-grid-large {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tf-photo-slot,
|
||||
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.tf-user-menu {
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-radius: 999px;
|
||||
padding: 0.8rem 1rem;
|
||||
box-shadow: var(--tf-shadow-soft);
|
||||
color: var(--tf-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-user-menu:hover,
|
||||
.tf-user-menu:focus {
|
||||
color: var(--tf-primary-deep);
|
||||
}
|
||||
|
||||
.tf-user-dropdown {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: var(--tf-shadow-soft);
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tf-user-dropdown .dropdown-item {
|
||||
font-weight: 600;
|
||||
color: var(--tf-secondary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tf-user-dropdown .dropdown-item:hover,
|
||||
.tf-user-dropdown .dropdown-item:focus,
|
||||
.tf-logout-link:hover,
|
||||
.tf-logout-link:focus {
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: var(--tf-primary-deep);
|
||||
}
|
||||
|
||||
.tf-logout-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.tf-auth-card {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tf-auth-points {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.tf-user-menu {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.tf-workspace-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
border: 1px solid rgba(15, 118, 110, 0.16);
|
||||
color: #0f3f3b;
|
||||
}
|
||||
|
||||
.tf-workspace-chip strong,
|
||||
.tf-dropdown-label strong {
|
||||
display: block;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.tf-workspace-chip small,
|
||||
.tf-dropdown-label span {
|
||||
display: block;
|
||||
color: rgba(30, 41, 59, 0.72);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tf-workspace-chip-mark {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0f766e, #14b8a6);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-dropdown-label,
|
||||
.tf-dropdown-section {
|
||||
color: #0f172a;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.tf-workspace-switch {
|
||||
border-radius: 0.85rem;
|
||||
}
|
||||
|
||||
.tf-workspace-switch small {
|
||||
display: block;
|
||||
color: rgba(30, 41, 59, 0.7);
|
||||
}
|
||||
|
||||
.tf-workspace-switch.active {
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
}
|
||||
|
||||
.tf-inline-stat {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
border: 1px solid rgba(15, 118, 110, 0.15);
|
||||
}
|
||||
|
||||
.tf-inline-stat span {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: rgba(30, 41, 59, 0.7);
|
||||
}
|
||||
|
||||
.tf-inline-stat strong {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.tf-role-card,
|
||||
.tf-team-member {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.tf-role-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tf-role-card span {
|
||||
color: rgba(30, 41, 59, 0.76);
|
||||
}
|
||||
|
||||
.tf-team-member-active {
|
||||
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
.tf-role-pill {
|
||||
background: rgba(249, 115, 22, 0.14);
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
@ -1,21 +1,840 @@
|
||||
|
||||
/* TrustForge design system */
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
--tf-bg: #f4f7f6;
|
||||
--tf-surface: rgba(255, 255, 255, 0.82);
|
||||
--tf-surface-strong: #ffffff;
|
||||
--tf-surface-dark: #0f172a;
|
||||
--tf-border: rgba(15, 23, 42, 0.08);
|
||||
--tf-primary: #0f766e;
|
||||
--tf-primary-deep: #115e59;
|
||||
--tf-secondary: #1e293b;
|
||||
--tf-accent: #f97316;
|
||||
--tf-accent-soft: #fff1e8;
|
||||
--tf-success: #15803d;
|
||||
--tf-text: #0f172a;
|
||||
--tf-muted: #64748b;
|
||||
--tf-shadow: 0 20px 60px rgba(15, 23, 42, 0.10);
|
||||
--tf-shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08);
|
||||
--tf-radius-xl: 28px;
|
||||
--tf-radius-lg: 22px;
|
||||
--tf-radius-md: 16px;
|
||||
--tf-spacing: 1.5rem;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body.trustforge-body {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: var(--tf-text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 35%),
|
||||
radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 28%),
|
||||
linear-gradient(180deg, #fbfcfb 0%, var(--tf-bg) 100%);
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, .navbar-brand, .tf-display, .tf-section-title, .tf-page-title {
|
||||
font-family: 'Space Grotesk', 'Inter', sans-serif;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tf-background-glow {
|
||||
position: fixed;
|
||||
border-radius: 999px;
|
||||
filter: blur(60px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.tf-background-glow-1 {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
top: 10%;
|
||||
left: -4%;
|
||||
}
|
||||
|
||||
.tf-background-glow-2 {
|
||||
width: 360px;
|
||||
height: 360px;
|
||||
background: rgba(249, 115, 22, 0.10);
|
||||
right: -6%;
|
||||
top: 12%;
|
||||
}
|
||||
|
||||
main, .tf-site-header, .tf-footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.py-lg-6 {
|
||||
padding-top: 5rem !important;
|
||||
padding-bottom: 5rem !important;
|
||||
}
|
||||
|
||||
.tf-navbar {
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
backdrop-filter: blur(18px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.68);
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.tf-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--tf-secondary);
|
||||
}
|
||||
|
||||
.tf-brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--tf-primary), #2dd4bf);
|
||||
color: white;
|
||||
box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28);
|
||||
}
|
||||
|
||||
.tf-nav-toggle {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tf-navbar .nav-link {
|
||||
color: var(--tf-muted);
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 0.95rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.tf-navbar .nav-link:hover,
|
||||
.tf-navbar .nav-link:focus {
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: var(--tf-primary-deep);
|
||||
}
|
||||
|
||||
.tf-btn {
|
||||
border-radius: 999px;
|
||||
padding: 0.85rem 1.35rem;
|
||||
font-weight: 700;
|
||||
border: 0;
|
||||
box-shadow: var(--tf-shadow-soft);
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.tf-btn:hover,
|
||||
.tf-btn:focus {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.tf-btn-primary {
|
||||
background: linear-gradient(135deg, var(--tf-primary), #14b8a6);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tf-btn-primary:hover,
|
||||
.tf-btn-primary:focus {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tf-btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--tf-secondary);
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.tf-alert {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 118, 110, 0.12);
|
||||
background: rgba(236, 253, 245, 0.88);
|
||||
color: var(--tf-primary-deep);
|
||||
box-shadow: var(--tf-shadow-soft);
|
||||
}
|
||||
|
||||
.tf-hero-section {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tf-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: var(--tf-primary-deep);
|
||||
padding: 0.5rem 0.95rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.tf-eyebrow-light {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.tf-display {
|
||||
font-size: clamp(2.75rem, 7vw, 5.2rem);
|
||||
line-height: 0.98;
|
||||
max-width: 13ch;
|
||||
}
|
||||
|
||||
.tf-lead,
|
||||
.tf-page-subtitle {
|
||||
color: var(--tf-muted);
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.75;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.tf-page-title {
|
||||
font-size: clamp(2rem, 4vw, 3.3rem);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tf-section-title {
|
||||
font-size: clamp(1.75rem, 3.2vw, 2.6rem);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tf-stat-row span,
|
||||
.tf-metric-card span {
|
||||
display: block;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
color: var(--tf-secondary);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tf-stat-chip,
|
||||
.tf-metric-card {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(255, 255, 255, 0.7);
|
||||
border-radius: var(--tf-radius-md);
|
||||
padding: 1.1rem 1rem;
|
||||
box-shadow: var(--tf-shadow-soft);
|
||||
color: var(--tf-muted);
|
||||
}
|
||||
|
||||
.tf-device-card,
|
||||
.tf-panel,
|
||||
.tf-proof-card,
|
||||
.tf-empty-state {
|
||||
background: var(--tf-surface);
|
||||
backdrop-filter: blur(18px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.75);
|
||||
border-radius: var(--tf-radius-xl);
|
||||
box-shadow: var(--tf-shadow);
|
||||
}
|
||||
|
||||
.tf-device-card {
|
||||
padding: 1.25rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tf-device-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.18), transparent);
|
||||
border-radius: 36px;
|
||||
right: -30px;
|
||||
bottom: -40px;
|
||||
transform: rotate(18deg);
|
||||
}
|
||||
|
||||
.tf-device-header {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tf-device-header span {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.tf-device-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tf-mini-step,
|
||||
.tf-activity-row,
|
||||
.tf-proof-mini {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tf-mini-step.active {
|
||||
color: var(--tf-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-proof-preview,
|
||||
.tf-panel {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.tf-panel-dark {
|
||||
background: linear-gradient(135deg, #0f172a, #1e293b);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.tf-panel-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 118, 110, 0.10);
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.tf-proof-media-grid,
|
||||
.tf-proof-media-grid-large,
|
||||
.tf-proof-media-grid-static {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.tf-proof-media-grid-static {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tf-photo-slot {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
min-height: 170px;
|
||||
border-radius: 20px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0)),
|
||||
linear-gradient(135deg, rgba(15, 23, 42, 0.88), rgba(15, 118, 110, 0.82));
|
||||
color: rgba(255,255,255,0.92);
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: start;
|
||||
padding: 1rem;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.10);
|
||||
}
|
||||
|
||||
.tf-photo-slot::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0.56));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.tf-photo-slot-after {
|
||||
background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86));
|
||||
}
|
||||
|
||||
.tf-photo-slot-has-media {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
}
|
||||
|
||||
.tf-photo-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.tf-photo-label {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.tf-empty-proof {
|
||||
border-radius: 22px;
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.tf-proof-card-link {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tf-proof-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.tf-proof-card-body {
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.tf-card-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: var(--tf-primary-deep);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-badge-verified,
|
||||
.tf-rating-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.42rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tf-badge-verified {
|
||||
background: rgba(21, 128, 61, 0.10);
|
||||
color: var(--tf-success);
|
||||
}
|
||||
|
||||
.tf-rating-pill {
|
||||
background: var(--tf-accent-soft);
|
||||
color: var(--tf-accent);
|
||||
}
|
||||
|
||||
.tf-status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border-radius: 999px;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tf-status-completed,
|
||||
.tf-status-draft {
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
color: var(--tf-secondary);
|
||||
}
|
||||
|
||||
.tf-status-review_requested,
|
||||
.tf-status-sent,
|
||||
.tf-status-viewed {
|
||||
background: rgba(249, 115, 22, 0.12);
|
||||
color: var(--tf-accent);
|
||||
}
|
||||
|
||||
.tf-status-proof_ready,
|
||||
.tf-status-published,
|
||||
.tf-status-responded {
|
||||
background: rgba(21, 128, 61, 0.12);
|
||||
color: var(--tf-success);
|
||||
}
|
||||
|
||||
.tf-status-hidden {
|
||||
background: rgba(100, 116, 139, 0.12);
|
||||
color: var(--tf-muted);
|
||||
}
|
||||
|
||||
.tf-inline-link {
|
||||
color: var(--tf-primary-deep);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-inline-link:hover,
|
||||
.tf-inline-link:focus {
|
||||
color: var(--tf-primary);
|
||||
}
|
||||
|
||||
.tf-activity-row-soft {
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
}
|
||||
|
||||
.tf-activity-link,
|
||||
.tf-proof-mini {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tf-proof-mini {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tf-table thead th {
|
||||
border-bottom-width: 0;
|
||||
color: var(--tf-muted);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1.1rem 1.25rem;
|
||||
}
|
||||
|
||||
.tf-table tbody td {
|
||||
padding: 1.1rem 1.25rem;
|
||||
border-color: rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tf-detail-box {
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
border-radius: 18px;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tf-detail-box span {
|
||||
display: block;
|
||||
color: var(--tf-muted);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.tf-detail-box strong {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tf-check-row {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tf-check-row span {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
color: var(--tf-primary-deep);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tf-check-field {
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tf-sticky-panel {
|
||||
top: 6rem;
|
||||
}
|
||||
|
||||
.tf-feedback-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tf-feedback-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 96px;
|
||||
border-radius: 22px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tf-feedback-option input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tf-feedback-option:has(input:checked) {
|
||||
border-color: rgba(15, 118, 110, 0.55);
|
||||
background: rgba(236, 253, 245, 0.95);
|
||||
color: var(--tf-primary-deep);
|
||||
box-shadow: 0 12px 28px rgba(15, 118, 110, 0.14);
|
||||
}
|
||||
|
||||
.tf-testimonial {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
color: var(--tf-secondary);
|
||||
border-left: 4px solid rgba(15, 118, 110, 0.22);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.tf-panel-centered {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.tf-footer {
|
||||
border-top: 1px solid rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.tf-footer-brand {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
min-height: 50px;
|
||||
border-radius: 16px;
|
||||
border-color: rgba(15, 23, 42, 0.10);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus,
|
||||
.form-check-input:focus,
|
||||
.btn:focus {
|
||||
border-color: rgba(15, 118, 110, 0.4);
|
||||
box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.tf-display {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tf-feedback-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.tf-device-card,
|
||||
.tf-panel,
|
||||
.tf-proof-card,
|
||||
.tf-empty-state {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.tf-proof-media-grid,
|
||||
.tf-proof-media-grid-large {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tf-photo-slot,
|
||||
.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot {
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.tf-user-menu {
|
||||
border: 1px solid rgba(15, 23, 42, 0.06);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-radius: 999px;
|
||||
padding: 0.8rem 1rem;
|
||||
box-shadow: var(--tf-shadow-soft);
|
||||
color: var(--tf-secondary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-user-menu:hover,
|
||||
.tf-user-menu:focus {
|
||||
color: var(--tf-primary-deep);
|
||||
}
|
||||
|
||||
.tf-user-dropdown {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: var(--tf-shadow-soft);
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tf-user-dropdown .dropdown-item {
|
||||
font-weight: 600;
|
||||
color: var(--tf-secondary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.tf-user-dropdown .dropdown-item:hover,
|
||||
.tf-user-dropdown .dropdown-item:focus,
|
||||
.tf-logout-link:hover,
|
||||
.tf-logout-link:focus {
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
color: var(--tf-primary-deep);
|
||||
}
|
||||
|
||||
.tf-logout-link {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.tf-auth-card {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tf-auth-points {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.tf-user-menu {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.tf-workspace-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
border: 1px solid rgba(15, 118, 110, 0.16);
|
||||
color: #0f3f3b;
|
||||
}
|
||||
|
||||
.tf-workspace-chip strong,
|
||||
.tf-dropdown-label strong {
|
||||
display: block;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.tf-workspace-chip small,
|
||||
.tf-dropdown-label span {
|
||||
display: block;
|
||||
color: rgba(30, 41, 59, 0.72);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tf-workspace-chip-mark {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0f766e, #14b8a6);
|
||||
color: white;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tf-dropdown-label,
|
||||
.tf-dropdown-section {
|
||||
color: #0f172a;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.tf-workspace-switch {
|
||||
border-radius: 0.85rem;
|
||||
}
|
||||
|
||||
.tf-workspace-switch small {
|
||||
display: block;
|
||||
color: rgba(30, 41, 59, 0.7);
|
||||
}
|
||||
|
||||
.tf-workspace-switch.active {
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
}
|
||||
|
||||
.tf-inline-stat {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(15, 118, 110, 0.08);
|
||||
border: 1px solid rgba(15, 118, 110, 0.15);
|
||||
}
|
||||
|
||||
.tf-inline-stat span {
|
||||
display: block;
|
||||
font-size: 0.78rem;
|
||||
color: rgba(30, 41, 59, 0.7);
|
||||
}
|
||||
|
||||
.tf-inline-stat strong {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.tf-role-card,
|
||||
.tf-team-member {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.tf-role-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tf-role-card span {
|
||||
color: rgba(30, 41, 59, 0.76);
|
||||
}
|
||||
|
||||
.tf-team-member-active {
|
||||
box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.18);
|
||||
}
|
||||
|
||||
.tf-role-pill {
|
||||
background: rgba(249, 115, 22, 0.14);
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user