diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc index 896bb4f..3a859bf 100644 Binary files a/config/__pycache__/__init__.cpython-311.pyc and b/config/__pycache__/__init__.cpython-311.pyc differ diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index d79d6a7..480eac2 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 8cf22af..317ce2b 100644 Binary files a/config/__pycache__/urls.cpython-311.pyc and b/config/__pycache__/urls.cpython-311.pyc differ diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc index a1b4aa7..c21cff0 100644 Binary files a/config/__pycache__/wsgi.cpython-311.pyc and b/config/__pycache__/wsgi.cpython-311.pyc differ diff --git a/config/settings.py b/config/settings.py index 291d043..7171816 100644 --- a/config/settings.py +++ b/config/settings.py @@ -2,12 +2,6 @@ Django settings for config project. Generated by 'django-admin startproject' using Django 5.2.7. - -For more information on this file, see -https://docs.djangoproject.com/en/5.2/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/5.2/ref/settings/ """ from pathlib import Path @@ -15,38 +9,32 @@ import os from dotenv import load_dotenv BASE_DIR = Path(__file__).resolve().parent.parent -load_dotenv(BASE_DIR.parent / ".env") +load_dotenv(BASE_DIR.parent / '.env') -SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "change-me") -DEBUG = os.getenv("DJANGO_DEBUG", "true").lower() == "true" +SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'change-me') +DEBUG = os.getenv('DJANGO_DEBUG', 'true').lower() == 'true' ALLOWED_HOSTS = [ - "127.0.0.1", - "localhost", - os.getenv("HOST_FQDN", ""), + '127.0.0.1', + 'localhost', + os.getenv('HOST_FQDN', ''), ] CSRF_TRUSTED_ORIGINS = [ origin for origin in [ - os.getenv("HOST_FQDN", ""), - os.getenv("CSRF_TRUSTED_ORIGIN", "") + os.getenv('HOST_FQDN', ''), + os.getenv('CSRF_TRUSTED_ORIGIN', ''), ] if origin ] CSRF_TRUSTED_ORIGINS = [ - f"https://{host}" if not host.startswith(("http://", "https://")) else host + f'https://{host}' if not host.startswith(('http://', 'https://')) else host for host in CSRF_TRUSTED_ORIGINS ] -# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy. SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True -SESSION_COOKIE_SAMESITE = "None" -CSRF_COOKIE_SAMESITE = "None" - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/ - -# Application definition +SESSION_COOKIE_SAMESITE = 'None' +CSRF_COOKIE_SAMESITE = 'None' INSTALLED_APPS = [ 'django.contrib.admin', @@ -65,8 +53,6 @@ MIDDLEWARE = [ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', - # Disable X-Frame-Options middleware to allow Flatlogic preview iframes. - # 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] X_FRAME_OPTIONS = 'ALLOWALL' @@ -83,7 +69,6 @@ TEMPLATES = [ 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', - # IMPORTANT: do not remove – injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp 'core.context_processors.project_context', ], }, @@ -92,10 +77,6 @@ TEMPLATES = [ WSGI_APPLICATION = 'config.wsgi.application' - -# Database -# https://docs.djangoproject.com/en/5.2/ref/settings/#databases - DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', @@ -110,73 +91,48 @@ DATABASES = { }, } - -# Password validation -# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators - AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, ] - -# Internationalization -# https://docs.djangoproject.com/en/5.2/topics/i18n/ - LANGUAGE_CODE = 'en-us' - TIME_ZONE = 'UTC' - USE_I18N = True - USE_TZ = True - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/5.2/howto/static-files/ - STATIC_URL = 'static/' -# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS. STATIC_ROOT = BASE_DIR / 'staticfiles' - STATICFILES_DIRS = [ BASE_DIR / 'static', BASE_DIR / 'assets', BASE_DIR / 'node_modules', ] +MEDIA_URL = 'media/' +MEDIA_ROOT = BASE_DIR / 'media' -# Email -EMAIL_BACKEND = os.getenv( - "EMAIL_BACKEND", - "django.core.mail.backends.smtp.EmailBackend" -) -EMAIL_HOST = os.getenv("EMAIL_HOST", "127.0.0.1") -EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587")) -EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") -EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") -EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true" -EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true" -DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "no-reply@example.com") +EMAIL_BACKEND = os.getenv('EMAIL_BACKEND', 'django.core.mail.backends.smtp.EmailBackend') +EMAIL_HOST = os.getenv('EMAIL_HOST', '127.0.0.1') +EMAIL_PORT = int(os.getenv('EMAIL_PORT', '587')) +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') +EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'true').lower() == 'true' +EMAIL_USE_SSL = os.getenv('EMAIL_USE_SSL', 'false').lower() == 'true' +DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'no-reply@example.com') CONTACT_EMAIL_TO = [ item.strip() - for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",") + for item in os.getenv('CONTACT_EMAIL_TO', DEFAULT_FROM_EMAIL).split(',') if item.strip() ] -# When both TLS and SSL flags are enabled, prefer SSL explicitly if EMAIL_USE_SSL: EMAIL_USE_TLS = False -# Default primary key field type -# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field + +LOGIN_URL = 'login' +LOGIN_REDIRECT_URL = 'dashboard' +LOGOUT_REDIRECT_URL = 'home' +PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/config/urls.py b/config/urls.py index bcfc074..e09822d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,29 +1,17 @@ """ URL configuration for config project. - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/5.2/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ -from django.contrib import admin -from django.urls import include, path from django.conf import settings from django.conf.urls.static import static +from django.contrib import admin +from django.urls import include, path urlpatterns = [ - path("admin/", admin.site.urls), - path("", include("core.urls")), + path('admin/', admin.site.urls), + path('', include('core.urls')), ] if settings.DEBUG: - urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets") + urlpatterns += static('/assets/', document_root=settings.BASE_DIR / 'assets') urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/core/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc index 3f553f6..c3387cb 100644 Binary files a/core/__pycache__/__init__.cpython-311.pyc and b/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a..61e8d17 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/apps.cpython-311.pyc b/core/__pycache__/apps.cpython-311.pyc index 2fa4a49..2af164d 100644 Binary files a/core/__pycache__/apps.cpython-311.pyc and b/core/__pycache__/apps.cpython-311.pyc differ diff --git a/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc index 75bf223..b09f643 100644 Binary files a/core/__pycache__/context_processors.cpython-311.pyc and b/core/__pycache__/context_processors.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000..725b63c Binary files /dev/null and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index a251b5f..5b26592 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000..399ccbb Binary files /dev/null and b/core/__pycache__/tests.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988..70a5c84 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 2f0989c..be47382 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 8c38f3f..ff0a787 100644 --- a/core/admin.py +++ b/core/admin.py @@ -1,3 +1,59 @@ from django.contrib import admin -# Register your models here. +from .models import Business, BusinessMembership, Customer, Feedback, Job, JobMedia, ProofCard, ReviewRequest + + +class JobMediaInline(admin.TabularInline): + model = JobMedia + extra = 0 + + +@admin.register(Business) +class BusinessAdmin(admin.ModelAdmin): + list_display = ('name', 'industry', 'primary_city', 'primary_state', 'is_active') + list_filter = ('industry', 'is_active') + search_fields = ('name', 'slug', 'primary_city') + prepopulated_fields = {'slug': ('name',)} + + +@admin.register(BusinessMembership) +class BusinessMembershipAdmin(admin.ModelAdmin): + list_display = ('user', 'business', 'role', 'created_at') + list_filter = ('role', 'business') + search_fields = ('user__email', 'user__first_name', 'user__last_name', 'business__name') + + +@admin.register(Customer) +class CustomerAdmin(admin.ModelAdmin): + list_display = ('full_name', 'business', 'city', 'state', 'email', 'phone') + list_filter = ('business', 'state') + search_fields = ('full_name', 'email', 'phone') + + +@admin.register(Job) +class JobAdmin(admin.ModelAdmin): + list_display = ('service_type', 'business', 'customer', 'city', 'state', 'completed_at', 'status') + list_filter = ('business', 'status', 'state', 'completed_at') + search_fields = ('service_type', 'customer__full_name', 'technician_name') + inlines = [JobMediaInline] + + +@admin.register(ReviewRequest) +class ReviewRequestAdmin(admin.ModelAdmin): + list_display = ('job', 'channel', 'status', 'sent_at', 'reviewed_at') + list_filter = ('channel', 'status') + search_fields = ('job__customer__full_name', 'job__service_type') + + +@admin.register(Feedback) +class FeedbackAdmin(admin.ModelAdmin): + list_display = ('review_request', 'experience', 'rating', 'follow_up_required', 'is_public_approved', 'created_at') + list_filter = ('experience', 'follow_up_required', 'is_public_approved') + search_fields = ('review_request__job__customer__full_name', 'testimonial') + + +@admin.register(ProofCard) +class ProofCardAdmin(admin.ModelAdmin): + list_display = ('job', 'customer_display_name', 'status', 'is_featured', 'rating', 'published_at') + list_filter = ('status', 'is_featured') + search_fields = ('customer_display_name', 'job__service_type', 'testimonial_quote') diff --git a/core/context_processors.py b/core/context_processors.py index 0bf87c3..f632765 100644 --- a/core/context_processors.py +++ b/core/context_processors.py @@ -1,13 +1,33 @@ import os import time +from .models import BusinessMembership + +ACTIVE_BUSINESS_SESSION_KEY = 'trustforge_active_business_id' + + def project_context(request): """ - Adds project-specific environment variables to the template context globally. + Adds project-specific environment variables and active workspace context globally. """ + current_membership = None + memberships = [] + if getattr(request, 'user', None) and request.user.is_authenticated: + memberships = list( + BusinessMembership.objects.select_related('business').filter( + user=request.user, + business__is_active=True, + ) + ) + active_business_id = request.session.get(ACTIVE_BUSINESS_SESSION_KEY) + current_membership = next((item for item in memberships if item.business_id == active_business_id), None) + if current_membership is None and memberships: + current_membership = memberships[0] + return { - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), - # Used for cache-busting static assets - "deployment_timestamp": int(time.time()), + 'project_description': os.getenv('PROJECT_DESCRIPTION', ''), + 'project_image_url': os.getenv('PROJECT_IMAGE_URL', ''), + 'deployment_timestamp': int(time.time()), + 'current_membership': current_membership, + 'user_memberships': memberships, } diff --git a/core/forms.py b/core/forms.py new file mode 100644 index 0000000..89cd421 --- /dev/null +++ b/core/forms.py @@ -0,0 +1,372 @@ +from __future__ import annotations + +import logging + +from django import forms +from django.contrib.auth import authenticate, get_user_model +from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm, SetPasswordForm, UserCreationForm +from django.utils import timezone + +from .models import Business, BusinessMembership, Feedback, ProofCard, ReviewRequest + +logger = logging.getLogger(__name__) +User = get_user_model() + + +class TrustForgeAuthenticationForm(AuthenticationForm): + username = forms.CharField( + label='Work email', + widget=forms.EmailInput( + attrs={ + 'class': 'form-control form-control-lg', + 'placeholder': 'you@company.com', + 'autocomplete': 'email', + } + ), + ) + password = forms.CharField( + label='Password', + strip=False, + widget=forms.PasswordInput( + attrs={ + 'class': 'form-control form-control-lg', + 'placeholder': 'Enter your password', + 'autocomplete': 'current-password', + } + ), + ) + + def clean(self): + email = (self.cleaned_data.get('username') or '').strip().lower() + password = self.cleaned_data.get('password') + + if email and password: + matched_user = User._default_manager.filter(email__iexact=email).first() + auth_username = matched_user.get_username() if matched_user else email + self.user_cache = authenticate(self.request, username=auth_username, password=password) + if self.user_cache is None: + raise self.get_invalid_login_error() + self.confirm_login_allowed(self.user_cache) + + return self.cleaned_data + + +class SignUpForm(UserCreationForm): + first_name = forms.CharField( + required=False, + max_length=150, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Avery'}), + ) + last_name = forms.CharField( + required=False, + max_length=150, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Stone'}), + ) + email = forms.EmailField( + widget=forms.EmailInput( + attrs={ + 'class': 'form-control form-control-lg', + 'placeholder': 'owner@servicebrand.com', + 'autocomplete': 'email', + } + ) + ) + password1 = forms.CharField( + label='Password', + strip=False, + widget=forms.PasswordInput( + attrs={ + 'class': 'form-control form-control-lg', + 'placeholder': 'Create a password', + 'autocomplete': 'new-password', + } + ), + ) + password2 = forms.CharField( + label='Confirm password', + strip=False, + widget=forms.PasswordInput( + attrs={ + 'class': 'form-control form-control-lg', + 'placeholder': 'Repeat your password', + 'autocomplete': 'new-password', + } + ), + ) + + class Meta(UserCreationForm.Meta): + model = User + fields = ('first_name', 'last_name', 'email', 'password1', 'password2') + + def clean_email(self): + email = (self.cleaned_data.get('email') or '').strip().lower() + if User._default_manager.filter(email__iexact=email).exists() or User._default_manager.filter(username__iexact=email).exists(): + raise forms.ValidationError('An account with this email already exists.') + return email + + def save(self, commit=True): + user = super().save(commit=False) + email = self.cleaned_data['email'] + user.username = email + user.email = email + user.first_name = self.cleaned_data.get('first_name', '').strip() + user.last_name = self.cleaned_data.get('last_name', '').strip() + if commit: + user.save() + return user + + +class ProfileSettingsForm(forms.ModelForm): + first_name = forms.CharField( + required=False, + max_length=150, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Avery'}), + ) + last_name = forms.CharField( + required=False, + max_length=150, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Stone'}), + ) + email = forms.EmailField( + widget=forms.EmailInput( + attrs={ + 'class': 'form-control form-control-lg', + 'placeholder': 'you@company.com', + 'autocomplete': 'email', + } + ) + ) + + class Meta: + model = User + fields = ('first_name', 'last_name', 'email') + + def clean_email(self): + email = (self.cleaned_data.get('email') or '').strip().lower() + email_exists = User._default_manager.filter(email__iexact=email).exclude(pk=self.instance.pk).exists() + username_exists = User._default_manager.filter(username__iexact=email).exclude(pk=self.instance.pk).exists() + if email_exists or username_exists: + raise forms.ValidationError('Another account already uses this email.') + return email + + def save(self, commit=True): + user = super().save(commit=False) + email = self.cleaned_data['email'] + user.email = email + user.username = email + user.first_name = self.cleaned_data.get('first_name', '').strip() + user.last_name = self.cleaned_data.get('last_name', '').strip() + if commit: + user.save() + return user + + +class TrustForgePasswordResetForm(PasswordResetForm): + email = forms.EmailField( + widget=forms.EmailInput( + attrs={ + 'class': 'form-control form-control-lg', + 'placeholder': 'you@company.com', + 'autocomplete': 'email', + } + ) + ) + + def send_mail(self, *args, **kwargs): + try: + return super().send_mail(*args, **kwargs) + except Exception: + logger.exception('Password reset email failed to send.') + + +class TrustForgeSetPasswordForm(SetPasswordForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['new_password1'].widget.attrs.update( + { + 'class': 'form-control form-control-lg', + 'placeholder': 'Create a new password', + 'autocomplete': 'new-password', + } + ) + self.fields['new_password2'].widget.attrs.update( + { + 'class': 'form-control form-control-lg', + 'placeholder': 'Confirm the new password', + 'autocomplete': 'new-password', + } + ) + + +class BusinessOnboardingForm(forms.ModelForm): + class Meta: + model = Business + fields = ('name', 'industry', 'primary_city', 'primary_state', 'google_review_url') + widgets = { + 'name': forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Summit Home Services'}), + 'industry': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'HVAC, Roofing, Plumbing, Junk Removal…'}), + 'primary_city': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Austin'}), + 'primary_state': forms.TextInput(attrs={'class': 'form-control text-uppercase', 'placeholder': 'TX'}), + 'google_review_url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'https://g.page/r/.../review'}), + } + + def clean_primary_state(self): + return (self.cleaned_data.get('primary_state') or '').upper() + + +class BusinessSettingsForm(BusinessOnboardingForm): + pass + + +class TeamMemberInviteForm(forms.Form): + first_name = forms.CharField( + required=False, + max_length=150, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Jamie'}), + ) + last_name = forms.CharField( + required=False, + max_length=150, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Rivera'}), + ) + email = forms.EmailField( + widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'tech@servicebrand.com'}) + ) + role = forms.ChoiceField( + choices=BusinessMembership.Role.choices, + initial=BusinessMembership.Role.TECHNICIAN, + widget=forms.Select(attrs={'class': 'form-select'}), + ) + + def clean_email(self): + return (self.cleaned_data.get('email') or '').strip().lower() + + +class JobIntakeForm(forms.Form): + business = forms.ModelChoiceField( + queryset=Business.objects.filter(is_active=True), + empty_label=None, + widget=forms.Select(attrs={'class': 'form-select form-control-lg'}), + ) + customer_name = forms.CharField( + max_length=160, + widget=forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Homeowner or business contact'}), + ) + customer_email = forms.EmailField( + required=False, + widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'customer@example.com'}), + ) + customer_phone = forms.CharField( + required=False, + max_length=40, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': '(555) 555-0123'}), + ) + service_type = forms.CharField( + max_length=120, + widget=forms.TextInput(attrs={'class': 'form-control form-control-lg', 'placeholder': 'Roof replacement, HVAC tune-up, junk removal…'}), + ) + description = forms.CharField( + required=False, + widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 3, 'placeholder': 'What did the crew complete on-site?'}), + ) + customer_city = forms.CharField( + max_length=120, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Austin'}), + ) + customer_state = forms.CharField( + max_length=2, + widget=forms.TextInput(attrs={'class': 'form-control text-uppercase', 'placeholder': 'TX'}), + ) + technician_name = forms.CharField( + required=False, + max_length=120, + widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Luis R.'}), + ) + completion_date = forms.DateField( + initial=timezone.localdate, + widget=forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), + ) + project_value = forms.DecimalField( + required=False, + min_value=0, + max_digits=10, + decimal_places=2, + widget=forms.NumberInput(attrs={'class': 'form-control', 'placeholder': '4500'}), + ) + before_photo = forms.FileField( + required=False, + widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'}), + ) + after_photo = forms.FileField( + required=False, + widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': 'image/*'}), + ) + anonymize_customer = forms.BooleanField( + required=False, + initial=True, + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}), + ) + send_review_request = forms.BooleanField( + required=False, + initial=True, + widget=forms.CheckboxInput(attrs={'class': 'form-check-input'}), + ) + review_channel = forms.ChoiceField( + choices=ReviewRequest.Channel.choices, + initial=ReviewRequest.Channel.EMAIL, + widget=forms.Select(attrs={'class': 'form-select'}), + ) + + def __init__(self, *args, business: Business | None = None, **kwargs): + super().__init__(*args, **kwargs) + if business is not None: + self.fields['business'].queryset = Business.objects.filter(pk=business.pk) + self.fields['business'].initial = business + self.fields['business'].widget = forms.HiddenInput() + + def clean_customer_state(self): + return self.cleaned_data['customer_state'].upper() + + +class PublicFeedbackForm(forms.Form): + experience = forms.ChoiceField( + choices=Feedback.Experience.choices, + widget=forms.RadioSelect, + ) + testimonial = forms.CharField( + required=False, + widget=forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': 'Tell us what stood out about the service…'}), + ) + + def clean(self): + cleaned_data = super().clean() + experience = cleaned_data.get('experience') + testimonial = (cleaned_data.get('testimonial') or '').strip() + if experience in {Feedback.Experience.GREAT, Feedback.Experience.GOOD} and len(testimonial) < 12: + self.add_error('testimonial', 'For positive feedback, add a short testimonial so it can power the proof card.') + return cleaned_data + + +class ProofCardForm(forms.ModelForm): + class Meta: + model = ProofCard + fields = [ + 'customer_display_name', + 'is_anonymized', + 'testimonial_quote', + 'rating', + 'status', + 'is_featured', + 'attached_widget_label', + 'attached_pages', + ] + widgets = { + 'customer_display_name': forms.TextInput(attrs={'class': 'form-control'}), + 'is_anonymized': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'testimonial_quote': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}), + 'rating': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 5}), + 'status': forms.Select(attrs={'class': 'form-select'}), + 'is_featured': forms.CheckboxInput(attrs={'class': 'form-check-input'}), + 'attached_widget_label': forms.TextInput(attrs={'class': 'form-control'}), + 'attached_pages': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), + } diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..6cf0ff7 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,143 @@ +# Generated by Django 5.2.7 on 2026-04-11 01:16 + +import core.models +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Business', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=160)), + ('slug', models.SlugField(unique=True)), + ('industry', models.CharField(default='Home Services', max_length=120)), + ('primary_city', models.CharField(max_length=120)), + ('primary_state', models.CharField(max_length=2)), + ('google_review_url', models.URLField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name_plural': 'businesses', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Customer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=160)), + ('email', models.EmailField(blank=True, max_length=254)), + ('phone', models.CharField(blank=True, max_length=40)), + ('city', models.CharField(max_length=120)), + ('state', models.CharField(max_length=2)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customers', to='core.business')), + ], + options={ + 'ordering': ['full_name'], + }, + ), + migrations.CreateModel( + name='Job', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('service_type', models.CharField(max_length=120)), + ('description', models.TextField(blank=True)), + ('technician_name', models.CharField(blank=True, max_length=120)), + ('city', models.CharField(max_length=120)), + ('state', models.CharField(max_length=2)), + ('completed_at', models.DateField(default=django.utils.timezone.localdate)), + ('project_value', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)), + ('status', models.CharField(choices=[('completed', 'Completed'), ('review_requested', 'Review requested'), ('proof_ready', 'Proof ready')], default='completed', max_length=32)), + ('is_verified', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='core.business')), + ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='core.customer')), + ], + options={ + 'ordering': ['-completed_at', '-created_at'], + }, + ), + migrations.CreateModel( + name='JobMedia', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('media_type', models.CharField(choices=[('before', 'Before'), ('after', 'After')], max_length=16)), + ('file', models.FileField(blank=True, upload_to=core.models.job_media_upload_path)), + ('caption', models.CharField(blank=True, max_length=140)), + ('display_order', models.PositiveSmallIntegerField(default=0)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media', to='core.job')), + ], + options={ + 'verbose_name_plural': 'job media', + 'ordering': ['display_order', 'id'], + }, + ), + migrations.CreateModel( + name='ProofCard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('customer_display_name', models.CharField(max_length=160)), + ('is_anonymized', models.BooleanField(default=False)), + ('testimonial_quote', models.TextField(blank=True)), + ('rating', models.PositiveSmallIntegerField(blank=True, null=True)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('hidden', 'Hidden')], default='draft', max_length=16)), + ('is_featured', models.BooleanField(default=False)), + ('attached_widget_label', models.CharField(blank=True, max_length=120)), + ('attached_pages', models.CharField(blank=True, max_length=200)), + ('published_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='proof_card', to='core.job')), + ], + options={ + 'ordering': ['-is_featured', '-updated_at'], + }, + ), + migrations.CreateModel( + name='ReviewRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('status', models.CharField(choices=[('sent', 'Sent'), ('viewed', 'Viewed'), ('responded', 'Responded')], default='sent', max_length=20)), + ('channel', models.CharField(choices=[('email', 'Email'), ('sms', 'SMS'), ('manual', 'Manual share')], default='email', max_length=16)), + ('sent_at', models.DateTimeField(default=django.utils.timezone.now)), + ('last_opened_at', models.DateTimeField(blank=True, null=True)), + ('reviewed_at', models.DateTimeField(blank=True, null=True)), + ('delivery_note', models.CharField(blank=True, max_length=200)), + ('job', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='review_request', to='core.job')), + ], + options={ + 'ordering': ['-sent_at'], + }, + ), + migrations.CreateModel( + name='Feedback', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('experience', models.CharField(choices=[('great', 'Great'), ('good', 'Good'), ('okay', 'Okay'), ('bad', 'Bad')], max_length=12)), + ('rating', models.PositiveSmallIntegerField(blank=True, null=True)), + ('testimonial', models.TextField(blank=True)), + ('follow_up_required', models.BooleanField(default=False)), + ('is_public_approved', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('review_request', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='feedback', to='core.reviewrequest')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/core/migrations/0002_seed_trustforge_demo.py b/core/migrations/0002_seed_trustforge_demo.py new file mode 100644 index 0000000..21cc3d2 --- /dev/null +++ b/core/migrations/0002_seed_trustforge_demo.py @@ -0,0 +1,98 @@ +from django.db import migrations +from django.utils import timezone + + +def seed_trustforge_demo(apps, schema_editor): + Business = apps.get_model('core', 'Business') + Customer = apps.get_model('core', 'Customer') + Job = apps.get_model('core', 'Job') + ProofCard = apps.get_model('core', 'ProofCard') + ReviewRequest = apps.get_model('core', 'ReviewRequest') + Feedback = apps.get_model('core', 'Feedback') + + business, _ = Business.objects.get_or_create( + slug='summit-home-services', + defaults={ + 'name': 'Summit Home Services', + 'industry': 'Roofing & Exterior', + 'primary_city': 'Austin', + 'primary_state': 'TX', + 'google_review_url': 'https://example.com/google-review', + 'is_active': True, + }, + ) + customer, _ = Customer.objects.get_or_create( + business=business, + full_name='Avery Johnson', + defaults={ + 'email': 'avery@example.com', + 'phone': '(512) 555-0148', + 'city': 'Austin', + 'state': 'TX', + }, + ) + job, _ = Job.objects.get_or_create( + business=business, + customer=customer, + service_type='Roof replacement', + defaults={ + 'description': 'Replaced weather-damaged shingles, sealed flashing, and completed a same-day cleanup.', + 'technician_name': 'Luis Ramirez', + 'city': 'Austin', + 'state': 'TX', + 'completed_at': timezone.localdate(), + 'project_value': '14250.00', + 'status': 'proof_ready', + 'is_verified': True, + }, + ) + proof_card, _ = ProofCard.objects.get_or_create( + job=job, + defaults={ + 'customer_display_name': 'Verified homeowner', + 'is_anonymized': True, + 'testimonial_quote': 'The crew communicated clearly, protected the landscaping, and finished the roof beautifully in one day.', + 'rating': 5, + 'status': 'published', + 'is_featured': True, + 'attached_widget_label': 'Homepage proof gallery', + 'attached_pages': 'Homepage, Roofing service page', + 'published_at': timezone.now(), + }, + ) + review_request, _ = ReviewRequest.objects.get_or_create( + job=job, + defaults={ + 'status': 'responded', + 'channel': 'email', + 'sent_at': timezone.now(), + 'reviewed_at': timezone.now(), + 'delivery_note': 'Seeded demo review request', + }, + ) + Feedback.objects.get_or_create( + review_request=review_request, + defaults={ + 'experience': 'great', + 'rating': 5, + 'testimonial': proof_card.testimonial_quote, + 'follow_up_required': False, + 'is_public_approved': True, + }, + ) + + +def remove_trustforge_demo(apps, schema_editor): + Business = apps.get_model('core', 'Business') + Business.objects.filter(slug='summit-home-services').delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.RunPython(seed_trustforge_demo, remove_trustforge_demo), + ] diff --git a/core/migrations/0003_businessmembership.py b/core/migrations/0003_businessmembership.py new file mode 100644 index 0000000..08b9b8b --- /dev/null +++ b/core/migrations/0003_businessmembership.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.7 on 2026-04-11 01:43 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_seed_trustforge_demo'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BusinessMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('manager', 'Manager'), ('technician', 'Technician')], default='owner', max_length=24)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('business', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='core.business')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='business_memberships', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['business__name', 'user__email'], + 'unique_together': {('business', 'user')}, + }, + ), + ] diff --git a/core/migrations/__pycache__/0001_initial.cpython-311.pyc b/core/migrations/__pycache__/0001_initial.cpython-311.pyc new file mode 100644 index 0000000..dd28f9b Binary files /dev/null and b/core/migrations/__pycache__/0001_initial.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0002_seed_trustforge_demo.cpython-311.pyc b/core/migrations/__pycache__/0002_seed_trustforge_demo.cpython-311.pyc new file mode 100644 index 0000000..52b5b77 Binary files /dev/null and b/core/migrations/__pycache__/0002_seed_trustforge_demo.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/0003_businessmembership.cpython-311.pyc b/core/migrations/__pycache__/0003_businessmembership.cpython-311.pyc new file mode 100644 index 0000000..55d6f78 Binary files /dev/null and b/core/migrations/__pycache__/0003_businessmembership.cpython-311.pyc differ diff --git a/core/migrations/__pycache__/__init__.cpython-311.pyc b/core/migrations/__pycache__/__init__.cpython-311.pyc index 7995815..fc6b5ce 100644 Binary files a/core/migrations/__pycache__/__init__.cpython-311.pyc and b/core/migrations/__pycache__/__init__.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 71a8362..2feec86 100644 --- a/core/models.py +++ b/core/models.py @@ -1,3 +1,215 @@ -from django.db import models +from __future__ import annotations -# Create your models here. +import uuid + +from django.conf import settings +from django.db import models +from django.utils import timezone + + +class Business(models.Model): + name = models.CharField(max_length=160) + slug = models.SlugField(unique=True) + industry = models.CharField(max_length=120, default='Home Services') + primary_city = models.CharField(max_length=120) + primary_state = models.CharField(max_length=2) + google_review_url = models.URLField(blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['name'] + verbose_name_plural = 'businesses' + + def __str__(self) -> str: + return self.name + + @property + def initials(self) -> str: + words = [chunk for chunk in self.name.split() if chunk] + if not words: + return 'TF' + return ''.join(word[0] for word in words[:2]).upper() + + +class BusinessMembership(models.Model): + class Role(models.TextChoices): + OWNER = 'owner', 'Owner' + ADMIN = 'admin', 'Admin' + MANAGER = 'manager', 'Manager' + TECHNICIAN = 'technician', 'Technician' + + business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='memberships') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='business_memberships') + role = models.CharField(max_length=24, choices=Role.choices, default=Role.OWNER) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['business__name', 'user__email'] + unique_together = ('business', 'user') + + def __str__(self) -> str: + identity = self.user.get_full_name() or self.user.email or self.user.username + return f'{identity} · {self.business.name} ({self.get_role_display()})' + + @property + def can_manage_workspace(self) -> bool: + return self.role in {self.Role.OWNER, self.Role.ADMIN} + + @property + def can_manage_proof(self) -> bool: + return self.role in {self.Role.OWNER, self.Role.ADMIN, self.Role.MANAGER} + + +class Customer(models.Model): + business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='customers') + full_name = models.CharField(max_length=160) + email = models.EmailField(blank=True) + phone = models.CharField(max_length=40, blank=True) + city = models.CharField(max_length=120) + state = models.CharField(max_length=2) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['full_name'] + + def __str__(self) -> str: + return f'{self.full_name} · {self.city}, {self.state}' + + +class Job(models.Model): + class Status(models.TextChoices): + COMPLETED = 'completed', 'Completed' + REVIEW_REQUESTED = 'review_requested', 'Review requested' + PROOF_READY = 'proof_ready', 'Proof ready' + + business = models.ForeignKey(Business, on_delete=models.CASCADE, related_name='jobs') + customer = models.ForeignKey(Customer, on_delete=models.CASCADE, related_name='jobs') + service_type = models.CharField(max_length=120) + description = models.TextField(blank=True) + technician_name = models.CharField(max_length=120, blank=True) + city = models.CharField(max_length=120) + state = models.CharField(max_length=2) + completed_at = models.DateField(default=timezone.localdate) + project_value = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + status = models.CharField(max_length=32, choices=Status.choices, default=Status.COMPLETED) + is_verified = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-completed_at', '-created_at'] + + def __str__(self) -> str: + return f'{self.service_type} for {self.customer.full_name}' + + @property + def before_media(self): + return self.media.filter(media_type=JobMedia.MediaType.BEFORE).first() + + @property + def after_media(self): + return self.media.filter(media_type=JobMedia.MediaType.AFTER).first() + + +def job_media_upload_path(instance: 'JobMedia', filename: str) -> str: + return f'jobs/job_{instance.job_id}/{instance.media_type}_{filename}' + + +class JobMedia(models.Model): + class MediaType(models.TextChoices): + BEFORE = 'before', 'Before' + AFTER = 'after', 'After' + + job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='media') + media_type = models.CharField(max_length=16, choices=MediaType.choices) + file = models.FileField(upload_to=job_media_upload_path, blank=True) + caption = models.CharField(max_length=140, blank=True) + display_order = models.PositiveSmallIntegerField(default=0) + + class Meta: + ordering = ['display_order', 'id'] + verbose_name_plural = 'job media' + + def __str__(self) -> str: + return f'{self.job} · {self.get_media_type_display()}' + + +class ReviewRequest(models.Model): + class Status(models.TextChoices): + SENT = 'sent', 'Sent' + VIEWED = 'viewed', 'Viewed' + RESPONDED = 'responded', 'Responded' + + class Channel(models.TextChoices): + EMAIL = 'email', 'Email' + SMS = 'sms', 'SMS' + MANUAL = 'manual', 'Manual share' + + job = models.OneToOneField(Job, on_delete=models.CASCADE, related_name='review_request') + token = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.SENT) + channel = models.CharField(max_length=16, choices=Channel.choices, default=Channel.EMAIL) + sent_at = models.DateTimeField(default=timezone.now) + last_opened_at = models.DateTimeField(null=True, blank=True) + reviewed_at = models.DateTimeField(null=True, blank=True) + delivery_note = models.CharField(max_length=200, blank=True) + + class Meta: + ordering = ['-sent_at'] + + def __str__(self) -> str: + return f'Review request for {self.job}' + + +class Feedback(models.Model): + class Experience(models.TextChoices): + GREAT = 'great', 'Great' + GOOD = 'good', 'Good' + OKAY = 'okay', 'Okay' + BAD = 'bad', 'Bad' + + review_request = models.OneToOneField(ReviewRequest, on_delete=models.CASCADE, related_name='feedback') + experience = models.CharField(max_length=12, choices=Experience.choices) + rating = models.PositiveSmallIntegerField(null=True, blank=True) + testimonial = models.TextField(blank=True) + follow_up_required = models.BooleanField(default=False) + is_public_approved = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self) -> str: + return f'{self.get_experience_display()} feedback for {self.review_request.job}' + + +class ProofCard(models.Model): + class Status(models.TextChoices): + DRAFT = 'draft', 'Draft' + PUBLISHED = 'published', 'Published' + HIDDEN = 'hidden', 'Hidden' + + job = models.OneToOneField(Job, on_delete=models.CASCADE, related_name='proof_card') + customer_display_name = models.CharField(max_length=160) + is_anonymized = models.BooleanField(default=False) + testimonial_quote = models.TextField(blank=True) + rating = models.PositiveSmallIntegerField(null=True, blank=True) + status = models.CharField(max_length=16, choices=Status.choices, default=Status.DRAFT) + is_featured = models.BooleanField(default=False) + attached_widget_label = models.CharField(max_length=120, blank=True) + attached_pages = models.CharField(max_length=200, blank=True) + published_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-is_featured', '-updated_at'] + + def __str__(self) -> str: + return f'Proof card · {self.job.service_type} · {self.customer_display_name}' + + @property + def verified_label(self) -> str: + return 'Verified job' if self.job.is_verified else 'Pending verification' diff --git a/core/templates/base.html b/core/templates/base.html index 1e7e5fb..80cc10c 100644 --- a/core/templates/base.html +++ b/core/templates/base.html @@ -1,25 +1,133 @@ - +{% load static %} + - - - {% block title %}Knowledge Base{% endblock %} - {% if project_description %} - - - - {% endif %} - {% if project_image_url %} - - - {% endif %} - {% load static %} + + + {% block title %}TrustForge{% endblock %} + + + + + + + {% block head %}{% endblock %} + +
+
- - {% block content %}{% endblock %} +
+ +
+ + {% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+ {% endif %} + +
+ {% block content %}{% endblock %} +
+ + + + - diff --git a/core/templates/core/business_onboarding.html b/core/templates/core/business_onboarding.html new file mode 100644 index 0000000..6c624ae --- /dev/null +++ b/core/templates/core/business_onboarding.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% block title %}Create Your Workspace | TrustForge{% endblock %} +{% block meta_description %}Create your TrustForge business workspace, set your service area, and start the protected proof pipeline.{% endblock %} + +{% block content %} +
+
+
+
+
+
Business onboarding
+

Create your first TrustForge workspace

+

This workspace becomes the protected home for your completed jobs, review requests, proof cards, and team permissions.

+
+
01 Create your business workspace
+
02 Become the owner automatically
+
03 Invite admins, managers, and technicians
+
04 Keep every job and proof asset scoped securely
+
+
+
+
+
+
+
+
Workspace details
+

Set up the business your team will operate inside

+

You can update branding details later from workspace settings. The trust pipeline and team access will use this business as the default scope.

+
+
+ Protected SaaS + Multi-tenant ready +
+
+
+ {% csrf_token %} +
+ + {{ form.name }} + {% if form.name.errors %}
{{ form.name.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.industry }} + {% if form.industry.errors %}
{{ form.industry.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.primary_city }} + {% if form.primary_city.errors %}
{{ form.primary_city.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.primary_state }} + {% if form.primary_state.errors %}
{{ form.primary_state.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.google_review_url }} + {% if form.google_review_url.errors %}
{{ form.google_review_url.errors|join:', ' }}
{% endif %} +
+ {% if form.non_field_errors %} +
{{ form.non_field_errors|join:', ' }}
+ {% endif %} +
+ + You will be assigned as the owner and land inside your dashboard immediately. +
+
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/dashboard.html b/core/templates/core/dashboard.html new file mode 100644 index 0000000..698cefe --- /dev/null +++ b/core/templates/core/dashboard.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}Dashboard | TrustForge{% endblock %} +{% block meta_description %}TrustForge dashboard for completed jobs, review requests, proof cards, and conversion momentum.{% endblock %} + +{% block content %} +
+
+
+
+
Dashboard
+

{{ current_membership.business.name }} proof momentum

+

This dashboard is scoped to your current workspace only. Track completed jobs, response volume, published proof, and the conversion signal your team is creating this week.

+
+
+ {% if current_membership.can_manage_workspace %}Workspace settings{% endif %} + Log a new completed job +
+
+ +
+
{{ stats.completed_jobs|default:0 }}

Jobs completed

+
{{ stats.review_requests|default:0 }}

Review requests

+
{{ stats.proof_cards|default:0 }}

Proof cards created

+
{{ conversion_rate }}%

Positive response rate

+
+ +
+
+
+
+

Recent jobs

+ View all +
+
+ {% for job in recent_jobs %} + +
+
+ {{ job.service_type }} +
{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }}
+
+ {{ job.get_status_display }} +
+
+ {% empty %} +
+

No jobs yet

+

Use the field intake form to start the first proof workflow for {{ current_membership.business.name }}.

+
+ {% endfor %} +
+
+
+
+
+
+

Recent proof cards

+ Open gallery +
+
+ {% for proof in recent_proofs %} + +
+
{{ proof.job.service_type }}
+ {{ proof.job.city }}, {{ proof.job.state }} +
{{ proof.customer_display_name }}
+
+ {{ proof.get_status_display }} +
+ {% empty %} +
+

No proof cards yet

+

Every completed job inside this workspace instantly creates a draft proof card.

+
+ {% endfor %} +
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/index.html b/core/templates/core/index.html index faec813..962013b 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -1,145 +1,195 @@ {% extends "base.html" %} -{% block title %}{{ project_name }}{% endblock %} - -{% block head %} - - - - -{% endblock %} +{% block title %}TrustForge | Turn completed jobs into proof that wins the next customer{% endblock %} +{% block meta_description %}TrustForge helps service businesses transform completed jobs into proof cards, review requests, and conversion assets that win more booked work.{% endblock %} {% block content %} -
-
-

Analyzing your requirements and generating your app…

-
- Loading… +
+
+
+
+
Trust engine for service businesses
+

Every completed job becomes proof that closes the next one.

+

TrustForge gives contractors, roofers, HVAC teams, plumbers, electricians, junk removal crews, and landscapers a fast field workflow: finish the job, send the review request, generate a premium proof card, and publish conversion-ready assets.

+ +
+
+
+ {{ stats.completed_jobs|default:0 }} + Jobs logged +
+
+
+
+ {{ stats.review_requests|default:0 }} + Requests sent +
+
+
+
+ {{ stats.proof_cards|default:0 }} + Proof cards +
+
+
+
+ {{ business_count }} + Active businesses +
+
+
+
+
+
+
+ +
+
+
1. Job completed
+
2. Review requested
+
3. Proof created
+
+ {% if featured_proofs %} + {% with proof=featured_proofs.0 %} +
+
Before
+
After
+
+
+
+
{{ proof.job.service_type }}
+

{{ proof.job.city }}, {{ proof.job.state }}

+
Verified completion · {{ proof.job.completed_at|date:"M j, Y" }}
+
+
{% if proof.rating %}★ {{ proof.rating }}.0{% else %}Verified{% endif %}
+
+

{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:120 }}”{% else %}Premium proof cards turn real field work into a conversion asset that belongs on your homepage and service pages.{% endif %}

+
+ {{ proof.customer_display_name }} + {{ proof.verified_label }} +
+ {% endwith %} + {% else %} +
+
+
Before
+
After
+
+

Your first proof card appears here

+

Log a completed job to instantly generate the draft card, review request, and proof pipeline.

+
+ {% endif %} +
+
+
+
-

AppWizzy AI is collecting your requirements and applying the first changes.

-

This page will refresh automatically as the plan is implemented.

-

- Runtime: Django {{ django_version }} · Python {{ python_version }} - — UTC {{ current_time|date:"Y-m-d H:i:s" }} -

-
- -{% endblock %} \ No newline at end of file + + +
+
+
+
+
+
+

Fast field workflow

+

Capture a completed job, upload before/after photos, and send a review request from a mobile-friendly form designed for technicians on-site.

+
+
+
+
+
🛡️
+

Proof-first cards

+

Every job creates a premium proof card with service, location, photos, verification status, testimonial, and publishing controls.

+
+
+
+
+
📈
+

Conversion-ready assets

+

Feature standout work on your landing page, service pages, and proof gallery to give new customers visible confidence.

+
+
+
+
+
+ +
+
+
+
+
Featured proof
+

Recent conversion assets

+
+ Browse proof cards +
+ +
+
+ +
+
+
+
+
+
Pipeline
+

Job Completed → Review Requested → Proof Card Created → Displayed → Converts Next Customer

+
+
+
+ {% for job in recent_jobs %} +
+
+ {{ job.service_type }} +
{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }}
+
+ {{ job.get_status_display }} +
+ {% empty %} +

No jobs yet. The first intake will immediately show up here.

+ {% endfor %} +
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/job_detail.html b/core/templates/core/job_detail.html new file mode 100644 index 0000000..ece481b --- /dev/null +++ b/core/templates/core/job_detail.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} + +{% block title %}{{ job.service_type }} | TrustForge{% endblock %} +{% block meta_description %}TrustForge job detail for {{ job.service_type }} in {{ job.city }}, {{ job.state }}.{% endblock %} + +{% block content %} +
+
+
+
+
Job detail
+

{{ job.service_type }}

+

{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }} · Completed {{ job.completed_at|date:"F j, Y" }}

+
+ +
+ +
+
+
+
+

Job summary

+ {{ job.get_status_display }} +
+
+
Business{{ job.business.name }}
+
Technician{{ job.technician_name|default:'Not added yet' }}
+
Project value{% if job.project_value %}${{ job.project_value }}{% else %}Optional{% endif %}
+
Verified{% if job.is_verified %}Yes{% else %}Pending{% endif %}
+
+
+

Description

+

{{ job.description|default:'No extra description was added for this completed job.' }}

+
+
+
+
+
{% if job.before_media %}Before photo{% else %}Before photo{% endif %}
+
+
+
+
+
{% if job.after_media %}After photo{% else %}After photo{% endif %}
+
+
+
+
+
+
+
+

Review request

+ {% if job.review_request %} +
Status{{ job.review_request.get_status_display }}
+
Channel{{ job.review_request.get_channel_display }}
+
Delivery note{{ job.review_request.delivery_note|default:'Ready to share' }}
+ Open customer review page + {% else %} +

No review request has been sent for this job yet.

+
+ {% csrf_token %} + + + +
+ {% endif %} +
+ +
+

Proof card

+
Status{{ job.proof_card.get_status_display }}
+
Display name{{ job.proof_card.customer_display_name }}
+
Widget target{{ job.proof_card.attached_widget_label|default:'Not set' }}
+ Manage proof card +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/job_form.html b/core/templates/core/job_form.html new file mode 100644 index 0000000..1af689b --- /dev/null +++ b/core/templates/core/job_form.html @@ -0,0 +1,114 @@ +{% extends "base.html" %} + +{% block title %}Complete a Job | TrustForge{% endblock %} +{% block meta_description %}Log a completed service job in TrustForge, upload before and after photos, and trigger the proof workflow.{% endblock %} + +{% block content %} +
+
+
+
+
+
Technician flow
+

Complete a job in under 30 seconds

+

You are logging this inside {{ current_membership.business.name }}. Add the customer, upload photos, and trigger a review request. TrustForge creates the proof card automatically.

+
+
01 Save the completed job
+
02 Draft proof card is generated
+
03 Review request is ready to share
+
04 Positive feedback can auto-publish proof
+
+
Workspace scope{{ current_membership.business.primary_city }}, {{ current_membership.business.primary_state }} · {{ current_membership.get_role_display }}
+
+
+
+
+
+
+
Job intake
+

Capture the completed work

+

Every field below feeds the trust pipeline: completed job → review request → proof card.

+
+
+ Business scope + {{ current_membership.business.name }} +
+
+
+ {% csrf_token %} + {{ form.business }} +
+ + {{ form.customer_name }} +
+
+ + {{ form.service_type }} +
+
+ + {{ form.customer_email }} +
+
+ + {{ form.customer_phone }} +
+
+ + {{ form.customer_city }} +
+
+ + {{ form.customer_state }} +
+
+ + {{ form.technician_name }} +
+
+ + {{ form.completion_date }} +
+
+ + {{ form.project_value }} +
+
+ + {{ form.review_channel }} +
+
+ + {{ form.description }} +
+
+ + {{ form.before_photo }} +
+
+ + {{ form.after_photo }} +
+
+
+ {{ form.anonymize_customer }} + +
+
+
+
+ {{ form.send_review_request }} + +
+
+
+ + Cancel +
+
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/jobs_list.html b/core/templates/core/jobs_list.html new file mode 100644 index 0000000..557fa68 --- /dev/null +++ b/core/templates/core/jobs_list.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}Jobs | TrustForge{% endblock %} +{% block meta_description %}Browse completed jobs in TrustForge and open each proof workflow detail.{% endblock %} + +{% block content %} +
+
+
+
+
Jobs
+

{{ current_membership.business.name }} completed job pipeline

+

Every job below belongs only to this workspace and can lead to a proof card, published testimonial, and higher conversion confidence.

+
+ Add completed job +
+ +
+ {% if jobs %} +
+ + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + {% endfor %} + +
CustomerServiceLocationCompletedStatus
+ {{ job.customer.full_name }} +
{{ job.technician_name|default:'Technician not added yet' }}
+
{{ job.service_type }}{{ job.city }}, {{ job.state }}{{ job.completed_at|date:"M j, Y" }}{{ job.get_status_display }}Open
+
+ {% else %} +
+

No jobs completed yet

+

Start with one field intake and TrustForge will spin up the proof workflow automatically for this business.

+ Create first job +
+ {% endif %} +
+
+
+{% endblock %} diff --git a/core/templates/core/profile_settings.html b/core/templates/core/profile_settings.html new file mode 100644 index 0000000..89f3120 --- /dev/null +++ b/core/templates/core/profile_settings.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} + +{% block title %}Profile & Settings | TrustForge{% endblock %} +{% block meta_description %}Manage your TrustForge profile, workspace memberships, and account settings.{% endblock %} + +{% block content %} +
+
+
+
+
Profile settings
+

Your account identity

+

Update your login profile and review which TrustForge workspaces you belong to.

+
+ {% if current_membership and current_membership.can_manage_workspace %} + Workspace settings + {% elif not current_membership %} + Create workspace + {% endif %} +
+ +
+
+
+

Account details

+
+ {% csrf_token %} +
+ + {{ form.first_name }} +
+
+ + {{ form.last_name }} +
+
+ + {{ form.email }} +
This email is also your login and password reset destination.
+
+
+ +
+
+
+
+
+
+

Workspace access

+
+ {% for membership in memberships %} +
+
+ {{ membership.business.name }} +
{{ membership.business.primary_city }}, {{ membership.business.primary_state }}
+
+
+ {{ membership.get_role_display }} + {% if current_membership and membership.business_id == current_membership.business_id %} +
Current workspace
+ {% endif %} +
+
+ {% empty %} +
+

No workspace yet

+

Create your first business workspace to turn TrustForge into a real tenant-scoped SaaS account.

+ Create workspace +
+ {% endfor %} +
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/proof_card_detail.html b/core/templates/core/proof_card_detail.html new file mode 100644 index 0000000..c96358e --- /dev/null +++ b/core/templates/core/proof_card_detail.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} + +{% block title %}Proof Card | TrustForge{% endblock %} +{% block meta_description %}View a TrustForge proof card and manage publishing controls for the active workspace.{% endblock %} + +{% block content %} +
+
+
+
+
Proof card
+

{{ proof_card.job.service_type }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}

+

{{ proof_card.customer_display_name }} · Scoped to {{ proof_card.job.business.name }} · Completed {{ proof_card.job.completed_at|date:"F j, Y" }}

+
+
+ All proof cards + {% if current_membership.can_manage_proof %}Edit proof card{% endif %} +
+
+ +
+
+
+
+
Before
+
After
+
+
+
+ {{ proof_card.job.service_type }} + {{ proof_card.verified_label }} +
+
{% if proof_card.testimonial_quote %}“{{ proof_card.testimonial_quote }}”{% else %}Add a testimonial or collect one through the review request page to strengthen this proof asset.{% endif %}
+
+
Rating{% if proof_card.rating %}★ {{ proof_card.rating }} / 5{% else %}Pending{% endif %}
+
Featured{% if proof_card.is_featured %}Yes{% else %}No{% endif %}
+
Widget target{{ proof_card.attached_widget_label|default:'Not set' }}
+
+
+
+
+
+
+

Quick actions

+ {% if current_membership.can_manage_proof %} +
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+
+ {% csrf_token %} + + +
+
+ {% else %} +
+

View-only access

+

Your current role can view proof assets in this workspace but cannot change publishing controls.

+
+ {% endif %} +
+
+

Placement

+
Attach to widgets{{ proof_card.attached_widget_label|default:'Homepage proof gallery' }}
+
Attach to pages{{ proof_card.attached_pages|default:'Homepage' }}
+ {% if proof_card.job.review_request %} + Open review request page + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/proof_card_form.html b/core/templates/core/proof_card_form.html new file mode 100644 index 0000000..bba4b8d --- /dev/null +++ b/core/templates/core/proof_card_form.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} + +{% block title %}Edit Proof Card | TrustForge{% endblock %} +{% block meta_description %}Edit a TrustForge proof card’s testimonial, status, featuring, and placement settings.{% endblock %} + +{% block content %} +
+
+
+
+
Edit proof card
+

Refine the conversion asset

+

Update the customer display name, testimonial, placement, and publish settings without touching the underlying job record.

+
+ Back to proof card +
+ +
+
+ {% csrf_token %} +
+ + {{ form.customer_display_name }} +
+
+ + {{ form.rating }} +
+
+ + {{ form.testimonial_quote }} +
+
+ + {{ form.status }} +
+
+ + {{ form.attached_widget_label }} +
+
+ + {{ form.attached_pages }} +
+
+
+ {{ form.is_anonymized }} + +
+
+
+
+ {{ form.is_featured }} + +
+
+
+ + Cancel +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/proof_cards_list.html b/core/templates/core/proof_cards_list.html new file mode 100644 index 0000000..305ff70 --- /dev/null +++ b/core/templates/core/proof_cards_list.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block title %}Proof Cards | TrustForge{% endblock %} +{% block meta_description %}Browse TrustForge proof cards scoped to your current workspace.{% endblock %} + +{% block content %} +
+
+
+
+
Proof cards
+

{{ current_membership.business.name }} proof gallery

+

These conversion assets are tenant-scoped to the active workspace and ready for review, publishing, or featuring.

+
+ Back to dashboard +
+ + +
+
+{% endblock %} diff --git a/core/templates/core/review_request.html b/core/templates/core/review_request.html new file mode 100644 index 0000000..91f1989 --- /dev/null +++ b/core/templates/core/review_request.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %}Share Feedback | TrustForge{% endblock %} +{% block meta_description %}Share feedback on your recent service experience and help create verified proof of work.{% endblock %} + +{% block content %} +
+
+
+
+
+
Customer feedback
+

How was your experience?

+

{{ job.business.name }} completed {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}. Your feedback helps verify real work and guide follow-up.

+ + {% if submitted %} +
+ {% if positive %} +

Thank you — this proof is ready to help the next customer.

+

Your feedback has been saved. The business can now publish this as verified proof of work.

+ {% if redirect_url %} + Continue to Google review + {% endif %} + {% else %} +

Thanks for the feedback.

+

This response stays internal so the business can follow up directly and improve the experience.

+ {% endif %} +
+ {% else %} +
+ {% csrf_token %} +
+ {% for radio in form.experience %} + + {% endfor %} +
+ {% if form.experience.errors %}
{{ form.experience.errors|join:', ' }}
{% endif %} +
+ + {{ form.testimonial }} + {% if form.testimonial.errors %}
{{ form.testimonial.errors|join:', ' }}
{% endif %} +
+ +
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/workspace_settings.html b/core/templates/core/workspace_settings.html new file mode 100644 index 0000000..3790a25 --- /dev/null +++ b/core/templates/core/workspace_settings.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} + +{% block title %}Workspace Settings | TrustForge{% endblock %} +{% block meta_description %}Manage your TrustForge workspace, business profile, and team roles.{% endblock %} + +{% block content %} +
+
+
+
+
Workspace settings
+

{{ current_membership.business.name }}

+

Manage the business profile, service territory, review destination, and who has access to this protected trust engine.

+
+ Back to dashboard +
+ +
+
+
+
+
+

Business profile

+

These details anchor onboarding, workspace identity, and public review routing.

+
+
+ Current role + {{ current_membership.get_role_display }} +
+
+
+ {% csrf_token %} + +
+ + {{ business_form.name }} +
+
+ + {{ business_form.industry }} +
+
+ + {{ business_form.primary_city }} +
+
+ + {{ business_form.primary_state }} +
+
+ + {{ business_form.google_review_url }} +
+
+ +
+
+
+
+
+
+

Role access model

+
+
Owner / AdminManage workspace settings, team access, jobs, and proof publishing.
+
ManagerRun the job-to-proof workflow and edit proof cards, but not workspace administration.
+
TechnicianLog completed jobs and view pipeline activity inside the assigned business only.
+
+
+
+
+ +
+
+
+

Add team member

+

Attach a user to this workspace. New users can use the forgot-password flow to activate access if they do not have a password yet.

+
+ {% csrf_token %} + +
+ + {{ invite_form.first_name }} +
+
+ + {{ invite_form.last_name }} +
+
+ + {{ invite_form.email }} +
+
+ + {{ invite_form.role }} +
+
+ +
+
+
+
+
+
+
+

Workspace team

+ {{ team_members|length }} seats +
+
+ {% for membership in team_members %} +
+
+ {{ membership.user.get_full_name|default:membership.user.email }} +
{{ membership.user.email }}
+
+
+ {{ membership.get_role_display }} +
Joined {{ membership.created_at|date:"M j, Y" }}
+
+
+ {% empty %} +
+

No team members yet

+

Invite admins, managers, and technicians to turn this workspace into a real multi-user SaaS account.

+
+ {% endfor %} +
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/registration/login.html b/core/templates/registration/login.html new file mode 100644 index 0000000..fa08393 --- /dev/null +++ b/core/templates/registration/login.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %}Login | TrustForge{% endblock %} +{% block meta_description %}Sign in to TrustForge to manage jobs, review requests, and proof cards securely.{% endblock %} + +{% block content %} +
+
+
+
+
+
Secure access
+

{{ auth_page_title }}

+

{{ auth_page_description }}

+
+
Access dashboard, jobs, and proof cards securely
+
Preserve the premium TrustForge workflow without exposing customer data publicly
+
Next step adds business onboarding and role-based team access
+
+
+
+
+
+
Login
+

Continue into TrustForge

+

Use your work email and password to open the product workspace.

+
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors|join:', ' }}
+ {% endif %} +
+ + {{ form.username }} + {% if form.username.errors %}
{{ form.username.errors|join:', ' }}
{% endif %} +
+
+
+ + Forgot password? +
+ {{ form.password }} + {% if form.password.errors %}
{{ form.password.errors|join:', ' }}
{% endif %} +
+ {% if next %}{% endif %} + +
+

New to TrustForge? Create your account

+
+
+
+
+
+{% endblock %} diff --git a/core/templates/registration/password_reset_complete.html b/core/templates/registration/password_reset_complete.html new file mode 100644 index 0000000..b6ffeb5 --- /dev/null +++ b/core/templates/registration/password_reset_complete.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Password Updated | TrustForge{% endblock %} +{% block meta_description %}Your TrustForge password has been updated successfully.{% endblock %} + +{% block content %} +
+
+
+
+
+
All set
+

{{ auth_page_title }}

+

{{ auth_page_description }}

+ Log in now +
+
+
+
+
+{% endblock %} diff --git a/core/templates/registration/password_reset_confirm.html b/core/templates/registration/password_reset_confirm.html new file mode 100644 index 0000000..050bf4a --- /dev/null +++ b/core/templates/registration/password_reset_confirm.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block title %}Set New Password | TrustForge{% endblock %} +{% block meta_description %}Create a new password for your TrustForge account securely.{% endblock %} + +{% block content %} +
+
+
+
+
+
Create new password
+

{{ auth_page_title }}

+

{{ auth_page_description }}

+ {% if validlink %} +
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors|join:', ' }}
+ {% endif %} +
+ + {{ form.new_password1 }} + {% if form.new_password1.errors %}
{{ form.new_password1.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.new_password2 }} + {% if form.new_password2.errors %}
{{ form.new_password2.errors|join:', ' }}
{% endif %} +
+ +
+ {% else %} +
+

Reset link unavailable

+

This reset link is invalid or has already been used. Request a fresh one below.

+
+ Request a new link + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/core/templates/registration/password_reset_done.html b/core/templates/registration/password_reset_done.html new file mode 100644 index 0000000..c827581 --- /dev/null +++ b/core/templates/registration/password_reset_done.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Check Your Email | TrustForge{% endblock %} +{% block meta_description %}Password reset instructions for your TrustForge account have been sent if the email exists.{% endblock %} + +{% block content %} +
+
+
+
+
+
Email sent
+

{{ auth_page_title }}

+

{{ auth_page_description }}

+
+

Next step

+

Open the email from TrustForge and follow the secure link to create a new password.

+
+ Return to login +
+
+
+
+
+{% endblock %} diff --git a/core/templates/registration/password_reset_email.txt b/core/templates/registration/password_reset_email.txt new file mode 100644 index 0000000..6294c69 --- /dev/null +++ b/core/templates/registration/password_reset_email.txt @@ -0,0 +1,10 @@ +Hi, + +We received a request to reset the password for your TrustForge account. + +Use the link below to choose a new password: +{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %} + +If you did not request this, you can ignore this email. + +— TrustForge diff --git a/core/templates/registration/password_reset_form.html b/core/templates/registration/password_reset_form.html new file mode 100644 index 0000000..eedf2cf --- /dev/null +++ b/core/templates/registration/password_reset_form.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}Forgot Password | TrustForge{% endblock %} +{% block meta_description %}Request a secure password reset link for your TrustForge account.{% endblock %} + +{% block content %} +
+
+
+
+
+
Password reset
+

{{ auth_page_title }}

+

{{ auth_page_description }}

+
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors|join:', ' }}
+ {% endif %} +
+ + {{ form.email }} + {% if form.email.errors %}
{{ form.email.errors|join:', ' }}
{% endif %} +
+ +
+

Back to login

+
+
+
+
+
+{% endblock %} diff --git a/core/templates/registration/password_reset_subject.txt b/core/templates/registration/password_reset_subject.txt new file mode 100644 index 0000000..ec070af --- /dev/null +++ b/core/templates/registration/password_reset_subject.txt @@ -0,0 +1 @@ +Reset your TrustForge password diff --git a/core/templates/registration/signup.html b/core/templates/registration/signup.html new file mode 100644 index 0000000..3288724 --- /dev/null +++ b/core/templates/registration/signup.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} + +{% block title %}Get Started | TrustForge{% endblock %} +{% block meta_description %}Create your TrustForge account and start turning completed jobs into proof that wins the next customer.{% endblock %} + +{% block content %} +
+
+
+
+
+
Get started
+

{{ auth_page_title }}

+

{{ auth_page_description }}

+
+
01
Create secure email/password access
+
02
Land inside the product instead of a demo-only experience
+
03
Next step will connect your account to a business workspace
+
+
+
+
+
+
Sign up
+

Start your TrustForge workspace

+

Create the account that will own your dashboard access and future business onboarding.

+
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors|join:', ' }}
+ {% endif %} +
+
+ + {{ form.first_name }} + {% if form.first_name.errors %}
{{ form.first_name.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.last_name }} + {% if form.last_name.errors %}
{{ form.last_name.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.email }} + {% if form.email.errors %}
{{ form.email.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.password1 }} + {% if form.password1.errors %}
{{ form.password1.errors|join:', ' }}
{% endif %} +
+
+ + {{ form.password2 }} + {% if form.password2.errors %}
{{ form.password2.errors|join:', ' }}
{% endif %} +
+
+ +
+

Already have an account? Log in

+
+
+
+
+
+{% endblock %} diff --git a/core/tests.py b/core/tests.py index 7ce503c..a15c3e2 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +1,125 @@ +from django.contrib.auth import get_user_model from django.test import TestCase +from django.urls import reverse -# Create your tests here. +from .models import Business, BusinessMembership, Customer, Job, ProofCard, ReviewRequest + +User = get_user_model() + + +class TrustForgeFlowTests(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username='owner@example.com', + email='owner@example.com', + password='StrongPass123!', + ) + self.business = Business.objects.create( + name='Forge Roofing', + slug='forge-roofing', + industry='Roofing', + primary_city='Austin', + primary_state='TX', + google_review_url='https://example.com/google-review', + ) + self.membership = BusinessMembership.objects.create( + user=self.user, + business=self.business, + role=BusinessMembership.Role.OWNER, + ) + self.customer = Customer.objects.create( + business=self.business, + full_name='Jordan Lee', + email='jordan@example.com', + city='Austin', + state='TX', + ) + self.job = Job.objects.create( + business=self.business, + customer=self.customer, + service_type='Roof repair', + city='Austin', + state='TX', + ) + self.proof_card = ProofCard.objects.create(job=self.job, customer_display_name='Verified homeowner') + self.review_request = ReviewRequest.objects.create(job=self.job) + + def test_home_loads(self): + response = self.client.get(reverse('home')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'TrustForge') + + def test_dashboard_requires_login(self): + response = self.client.get(reverse('dashboard')) + self.assertEqual(response.status_code, 302) + self.assertIn(reverse('login'), response.url) + + def test_login_with_email_redirects_to_dashboard(self): + response = self.client.post( + reverse('login'), + {'username': 'owner@example.com', 'password': 'StrongPass123!'}, + ) + self.assertRedirects(response, reverse('dashboard')) + + def test_logged_in_user_without_membership_redirects_to_onboarding(self): + user = User.objects.create_user( + username='solo@example.com', + email='solo@example.com', + password='StrongPass123!', + ) + self.client.force_login(user) + response = self.client.get(reverse('dashboard')) + self.assertRedirects(response, reverse('business_onboarding')) + + def test_dashboard_only_shows_active_business_data(self): + other_business = Business.objects.create( + name='Hidden Plumbing', + slug='hidden-plumbing', + industry='Plumbing', + primary_city='Dallas', + primary_state='TX', + ) + other_customer = Customer.objects.create( + business=other_business, + full_name='Taylor Shade', + city='Dallas', + state='TX', + ) + Job.objects.create( + business=other_business, + customer=other_customer, + service_type='Leak repair', + city='Dallas', + state='TX', + ) + + self.client.force_login(self.user) + response = self.client.get(reverse('dashboard')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Forge Roofing proof momentum') + self.assertNotContains(response, 'Leak repair') + + def test_technician_cannot_edit_proof_card(self): + technician = User.objects.create_user( + username='tech@example.com', + email='tech@example.com', + password='StrongPass123!', + ) + BusinessMembership.objects.create( + user=technician, + business=self.business, + role=BusinessMembership.Role.TECHNICIAN, + ) + self.client.force_login(technician) + response = self.client.get(reverse('proof_card_edit', args=[self.proof_card.id])) + self.assertEqual(response.status_code, 403) + + def test_public_review_positive_feedback_publishes_proof(self): + response = self.client.post( + reverse('review_request', args=[self.review_request.token]), + {'experience': 'great', 'testimonial': 'They showed up on time and the roof looks incredible.'}, + ) + self.assertEqual(response.status_code, 200) + self.proof_card.refresh_from_db() + self.assertEqual(self.proof_card.status, 'published') + self.assertEqual(self.proof_card.rating, 5) diff --git a/core/urls.py b/core/urls.py index 6299e3d..c2c924e 100644 --- a/core/urls.py +++ b/core/urls.py @@ -1,7 +1,47 @@ +from django.contrib.auth.views import LogoutView from django.urls import path -from .views import home +from .views import ( + TrustForgeLoginView, + TrustForgePasswordResetCompleteView, + TrustForgePasswordResetConfirmView, + TrustForgePasswordResetDoneView, + TrustForgePasswordResetView, + business_onboarding, + dashboard, + home, + job_create, + job_detail, + jobs_list, + profile_settings, + proof_card_detail, + proof_card_edit, + proof_cards_list, + review_request_view, + signup, + switch_workspace, + workspace_settings, +) urlpatterns = [ - path("", home, name="home"), + path('', home, name='home'), + path('login/', TrustForgeLoginView.as_view(), name='login'), + path('signup/', signup, name='signup'), + path('logout/', LogoutView.as_view(), name='logout'), + path('forgot-password/', TrustForgePasswordResetView.as_view(), name='password_reset'), + path('forgot-password/sent/', TrustForgePasswordResetDoneView.as_view(), name='password_reset_done'), + path('reset-password///', TrustForgePasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('reset-password/complete/', TrustForgePasswordResetCompleteView.as_view(), name='password_reset_complete'), + path('onboarding/business/', business_onboarding, name='business_onboarding'), + path('workspace//switch/', switch_workspace, name='switch_workspace'), + path('workspace/settings/', workspace_settings, name='workspace_settings'), + path('profile/', profile_settings, name='profile_settings'), + path('dashboard/', dashboard, name='dashboard'), + path('jobs/', jobs_list, name='jobs_list'), + path('jobs/new/', job_create, name='job_create'), + path('jobs//', job_detail, name='job_detail'), + path('proof-cards/', proof_cards_list, name='proof_cards_list'), + path('proof-cards//', proof_card_detail, name='proof_card_detail'), + path('proof-cards//edit/', proof_card_edit, name='proof_card_edit'), + path('reviews//', review_request_view, name='review_request'), ] diff --git a/core/views.py b/core/views.py index c9aed12..7dc086b 100644 --- a/core/views.py +++ b/core/views.py @@ -1,25 +1,686 @@ +from __future__ import annotations + import os import platform +from functools import wraps from django import get_version as django_version -from django.shortcuts import render +from django.contrib import messages +from django.contrib.auth import get_user_model, login +from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import ( + LoginView, + PasswordResetCompleteView, + PasswordResetConfirmView, + PasswordResetDoneView, + PasswordResetView, +) +from django.core.exceptions import PermissionDenied +from django.core.mail import send_mail +from django.db import transaction +from django.db.models import Count, Q +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse, reverse_lazy from django.utils import timezone +from django.utils.text import slugify + +from .forms import ( + BusinessOnboardingForm, + BusinessSettingsForm, + JobIntakeForm, + ProfileSettingsForm, + ProofCardForm, + PublicFeedbackForm, + SignUpForm, + TeamMemberInviteForm, + TrustForgeAuthenticationForm, + TrustForgePasswordResetForm, + TrustForgeSetPasswordForm, +) +from .models import Business, BusinessMembership, Customer, Feedback, Job, JobMedia, ProofCard, ReviewRequest + +User = get_user_model() +ACTIVE_BUSINESS_SESSION_KEY = 'trustforge_active_business_id' +POSITIVE_EXPERIENCES = {Feedback.Experience.GREAT, Feedback.Experience.GOOD} +RATING_MAP = { + Feedback.Experience.GREAT: 5, + Feedback.Experience.GOOD: 4, + Feedback.Experience.OKAY: 3, + Feedback.Experience.BAD: 2, +} -def home(request): - """Render the landing screen with loader and environment details.""" - host_name = request.get_host().lower() - agent_brand = "AppWizzy" if host_name == "appwizzy.com" else "Flatlogic" - now = timezone.now() +def _theme_context() -> dict: + return { + 'project_name': 'TrustForge', + 'project_description': ( + 'TrustForge turns completed service jobs into proof cards, testimonials, and conversion assets ' + 'for contractors, HVAC teams, roofers, plumbers, and local service businesses.' + ), + 'project_image_url': os.getenv('PROJECT_IMAGE_URL', ''), + } + + +class ThemedAuthContextMixin: + auth_page_title = 'TrustForge Account' + auth_page_description = 'Secure access to your proof pipeline, review engine, and published proof assets.' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update(_theme_context()) + context.setdefault('auth_page_title', self.auth_page_title) + context.setdefault('auth_page_description', self.auth_page_description) + return context + + +class TrustForgeLoginView(ThemedAuthContextMixin, LoginView): + template_name = 'registration/login.html' + authentication_form = TrustForgeAuthenticationForm + redirect_authenticated_user = True + auth_page_title = 'Welcome back to your trust engine' + auth_page_description = 'Sign in to manage completed jobs, review requests, proof cards, and every asset that helps the next customer say yes.' + + def get_success_url(self): + redirect_url = self.get_redirect_url() + if redirect_url: + return redirect_url + if _get_active_membership(self.request) is None: + return reverse('business_onboarding') + return reverse('dashboard') + + +class TrustForgePasswordResetView(ThemedAuthContextMixin, PasswordResetView): + template_name = 'registration/password_reset_form.html' + email_template_name = 'registration/password_reset_email.txt' + subject_template_name = 'registration/password_reset_subject.txt' + success_url = reverse_lazy('password_reset_done') + form_class = TrustForgePasswordResetForm + auth_page_title = 'Reset your TrustForge password' + auth_page_description = 'Enter your work email and we will send a secure reset link so you can get back into your proof pipeline.' + + +class TrustForgePasswordResetDoneView(ThemedAuthContextMixin, PasswordResetDoneView): + template_name = 'registration/password_reset_done.html' + auth_page_title = 'Check your email' + auth_page_description = 'If that email is tied to an account, a secure reset link is on its way.' + + +class TrustForgePasswordResetConfirmView(ThemedAuthContextMixin, PasswordResetConfirmView): + template_name = 'registration/password_reset_confirm.html' + form_class = TrustForgeSetPasswordForm + success_url = reverse_lazy('password_reset_complete') + auth_page_title = 'Create a new password' + auth_page_description = 'Set a new password for your account and return to the TrustForge dashboard securely.' + + +class TrustForgePasswordResetCompleteView(ThemedAuthContextMixin, PasswordResetCompleteView): + template_name = 'registration/password_reset_complete.html' + auth_page_title = 'Password updated' + auth_page_description = 'Your password has been changed successfully. You can sign back into TrustForge now.' + + +def _get_memberships_queryset(user): + return BusinessMembership.objects.select_related('business').filter(user=user, business__is_active=True) + + +def _get_user_memberships(request: HttpRequest) -> list[BusinessMembership]: + if not request.user.is_authenticated: + return [] + cached = getattr(request, '_trustforge_memberships', None) + if cached is None: + cached = list(_get_memberships_queryset(request.user)) + request._trustforge_memberships = cached + return cached + + +def _get_active_membership(request: HttpRequest) -> BusinessMembership | None: + if not request.user.is_authenticated: + return None + cached = getattr(request, '_trustforge_active_membership', None) + if cached is not None: + return cached + + memberships = _get_user_memberships(request) + active_business_id = request.session.get(ACTIVE_BUSINESS_SESSION_KEY) + membership = next((item for item in memberships if item.business_id == active_business_id), None) + if membership is None and memberships: + membership = memberships[0] + request._trustforge_active_membership = membership + return membership + + +def _set_active_membership(request: HttpRequest, business_id: int) -> None: + request.session[ACTIVE_BUSINESS_SESSION_KEY] = business_id + request._trustforge_active_membership = None + + +def _generate_unique_business_slug(name: str) -> str: + base_slug = slugify(name)[:45] or 'business' + candidate = base_slug + counter = 2 + while Business.objects.filter(slug=candidate).exists(): + candidate = f'{base_slug}-{counter}'[:55] + counter += 1 + return candidate + + +def business_required(view_func): + @wraps(view_func) + def wrapped(request: HttpRequest, *args, **kwargs): + if _get_active_membership(request) is None: + messages.info(request, 'Create or join a business workspace to unlock your protected TrustForge pipeline.') + return redirect('business_onboarding') + return view_func(request, *args, **kwargs) + + return wrapped + + +def membership_role_required(*allowed_roles: str): + def decorator(view_func): + @wraps(view_func) + def wrapped(request: HttpRequest, *args, **kwargs): + membership = _get_active_membership(request) + if membership is None: + messages.info(request, 'Create or join a business workspace to continue.') + return redirect('business_onboarding') + if membership.role not in allowed_roles: + raise PermissionDenied('Your role does not allow this action in the current workspace.') + return view_func(request, *args, **kwargs) + + return wrapped + + return decorator + + +def _build_review_link(request: HttpRequest, review_request: ReviewRequest) -> str: + return request.build_absolute_uri(reverse('review_request', args=[str(review_request.token)])) + + +@transaction.atomic +def _create_review_request(request: HttpRequest, job: Job, channel: str) -> ReviewRequest: + review_request, created = ReviewRequest.objects.get_or_create( + job=job, + defaults={ + 'channel': channel, + 'status': ReviewRequest.Status.SENT, + }, + ) + if created: + review_request.delivery_note = 'Share this link manually from the field app.' + if channel == ReviewRequest.Channel.EMAIL and job.customer.email: + review_link = _build_review_link(request, review_request) + email_sent = send_mail( + subject=f'How was your {job.service_type.lower()} experience?', + message=( + f'Hi {job.customer.full_name}\n\n' + f'Thanks for choosing {job.business.name}. Please share your feedback here: {review_link}\n\n' + 'Your response helps us build verified proof of work for future customers.' + ), + from_email=None, + recipient_list=[job.customer.email], + fail_silently=True, + ) + review_request.delivery_note = 'Email sent automatically.' if email_sent else 'Email backend unavailable — copy link manually.' + review_request.status = ReviewRequest.Status.SENT + review_request.sent_at = timezone.now() + review_request.save() + job.status = Job.Status.REVIEW_REQUESTED + job.save(update_fields=['status']) + return review_request + + +@transaction.atomic +def signup(request: HttpRequest) -> HttpResponse: + if request.user.is_authenticated: + return redirect('dashboard' if _get_active_membership(request) else 'business_onboarding') + + if request.method == 'POST': + form = SignUpForm(request.POST) + if form.is_valid(): + user = form.save() + login(request, user) + messages.success(request, 'Your TrustForge account is ready. Now let’s create your first business workspace.') + return redirect('business_onboarding') + else: + form = SignUpForm() context = { - "project_name": "New Style", - "agent_brand": agent_brand, - "django_version": django_version(), - "python_version": platform.python_version(), - "current_time": now, - "host_name": host_name, - "project_description": os.getenv("PROJECT_DESCRIPTION", ""), - "project_image_url": os.getenv("PROJECT_IMAGE_URL", ""), + **_theme_context(), + 'auth_page_title': 'Create your TrustForge account', + 'auth_page_description': 'Start with secure account access, then connect your business workspace, team roles, and protected proof pipeline.', + 'form': form, } - return render(request, "core/index.html", context) + return render(request, 'registration/signup.html', context) + + +@login_required +@transaction.atomic +def business_onboarding(request: HttpRequest) -> HttpResponse: + current_membership = _get_active_membership(request) + if current_membership is not None: + return redirect('dashboard') + + if request.method == 'POST': + form = BusinessOnboardingForm(request.POST) + if form.is_valid(): + business = form.save(commit=False) + business.slug = _generate_unique_business_slug(business.name) + business.save() + membership = BusinessMembership.objects.create( + business=business, + user=request.user, + role=BusinessMembership.Role.OWNER, + ) + _set_active_membership(request, membership.business_id) + messages.success(request, 'Workspace created. Your jobs, proof cards, and reviews are now scoped to this business.') + return redirect('dashboard') + else: + form = BusinessOnboardingForm() + + context = { + **_theme_context(), + 'form': form, + } + return render(request, 'core/business_onboarding.html', context) + + +@login_required +@transaction.atomic +def switch_workspace(request: HttpRequest, business_id: int) -> HttpResponse: + membership = get_object_or_404(_get_memberships_queryset(request.user), business_id=business_id) + _set_active_membership(request, membership.business_id) + messages.success(request, f'Workspace switched to {membership.business.name}.') + next_url = request.POST.get('next') or reverse('dashboard') + return redirect(next_url) + + +@login_required +@transaction.atomic +def profile_settings(request: HttpRequest) -> HttpResponse: + current_membership = _get_active_membership(request) + memberships = _get_user_memberships(request) + + if request.method == 'POST': + form = ProfileSettingsForm(request.POST, instance=request.user) + if form.is_valid(): + form.save() + messages.success(request, 'Profile settings updated.') + return redirect('profile_settings') + else: + form = ProfileSettingsForm(instance=request.user) + + context = { + **_theme_context(), + 'form': form, + 'current_membership': current_membership, + 'memberships': memberships, + } + return render(request, 'core/profile_settings.html', context) + + +@login_required +@membership_role_required(BusinessMembership.Role.OWNER, BusinessMembership.Role.ADMIN) +@transaction.atomic +def workspace_settings(request: HttpRequest) -> HttpResponse: + current_membership = _get_active_membership(request) + business = current_membership.business + team_members = BusinessMembership.objects.select_related('user').filter(business=business).order_by('created_at', 'id') + + if request.method == 'POST': + action = request.POST.get('action') + if action == 'update_business': + business_form = BusinessSettingsForm(request.POST, instance=business) + invite_form = TeamMemberInviteForm() + if business_form.is_valid(): + business_form.save() + messages.success(request, 'Workspace settings updated.') + return redirect('workspace_settings') + elif action == 'invite_member': + business_form = BusinessSettingsForm(instance=business) + invite_form = TeamMemberInviteForm(request.POST) + if invite_form.is_valid(): + email = invite_form.cleaned_data['email'] + user, created = User.objects.get_or_create( + email=email, + defaults={ + 'username': email, + 'email': email, + 'first_name': invite_form.cleaned_data.get('first_name', '').strip(), + 'last_name': invite_form.cleaned_data.get('last_name', '').strip(), + }, + ) + if created: + user.set_unusable_password() + user.save(update_fields=['password']) + else: + updated_fields = [] + first_name = invite_form.cleaned_data.get('first_name', '').strip() + last_name = invite_form.cleaned_data.get('last_name', '').strip() + if first_name and not user.first_name: + user.first_name = first_name + updated_fields.append('first_name') + if last_name and not user.last_name: + user.last_name = last_name + updated_fields.append('last_name') + if updated_fields: + user.save(update_fields=updated_fields) + + membership, membership_created = BusinessMembership.objects.update_or_create( + business=business, + user=user, + defaults={'role': invite_form.cleaned_data['role']}, + ) + if membership_created or created: + messages.success(request, 'Team member added. If this is a brand-new user, they can use “Forgot password” to set access.') + else: + messages.success(request, 'Team member role updated for this workspace.') + return redirect('workspace_settings') + else: + business_form = BusinessSettingsForm(instance=business) + invite_form = TeamMemberInviteForm() + else: + business_form = BusinessSettingsForm(instance=business) + invite_form = TeamMemberInviteForm() + + context = { + **_theme_context(), + 'business_form': business_form, + 'invite_form': invite_form, + 'current_membership': current_membership, + 'team_members': team_members, + } + return render(request, 'core/workspace_settings.html', context) + + +@transaction.atomic +def home(request: HttpRequest) -> HttpResponse: + businesses = Business.objects.count() + stats = Job.objects.aggregate( + completed_jobs=Count('id'), + review_requests=Count('review_request'), + proof_cards=Count('proof_card'), + published_proof=Count('proof_card', filter=Q(proof_card__status=ProofCard.Status.PUBLISHED)), + ) + featured_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter( + is_featured=True + )[:3] + recent_jobs = Job.objects.select_related('customer', 'business').prefetch_related('media')[:4] + + context = { + **_theme_context(), + 'django_version': django_version(), + 'python_version': platform.python_version(), + 'current_time': timezone.now(), + 'business_count': businesses, + 'stats': stats, + 'featured_proofs': featured_proofs, + 'recent_jobs': recent_jobs, + } + return render(request, 'core/index.html', context) + + +@login_required +@business_required +@transaction.atomic +def dashboard(request: HttpRequest) -> HttpResponse: + current_membership = _get_active_membership(request) + business = current_membership.business + jobs = Job.objects.filter(business=business) + stats = jobs.aggregate( + completed_jobs=Count('id'), + review_requests=Count('review_request'), + proof_cards=Count('proof_card'), + published_cards=Count('proof_card', filter=Q(proof_card__status=ProofCard.Status.PUBLISHED)), + ) + feedback_qs = Feedback.objects.filter(review_request__job__business=business) + positive_feedback = feedback_qs.filter(experience__in=POSITIVE_EXPERIENCES).count() + total_feedback = feedback_qs.count() + conversion_rate = round((positive_feedback / total_feedback) * 100, 1) if total_feedback else 0 + + recent_jobs = jobs.select_related('customer', 'business').prefetch_related('media')[:5] + recent_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter(job__business=business)[:4] + + context = { + **_theme_context(), + 'current_membership': current_membership, + 'stats': stats, + 'conversion_rate': conversion_rate, + 'recent_jobs': recent_jobs, + 'recent_proofs': recent_proofs, + } + return render(request, 'core/dashboard.html', context) + + +@login_required +@business_required +@transaction.atomic +def jobs_list(request: HttpRequest) -> HttpResponse: + current_membership = _get_active_membership(request) + jobs = Job.objects.select_related('customer', 'business').prefetch_related('media').filter(business=current_membership.business) + context = {**_theme_context(), 'jobs': jobs, 'current_membership': current_membership} + return render(request, 'core/jobs_list.html', context) + + +@login_required +@business_required +@transaction.atomic +def job_create(request: HttpRequest) -> HttpResponse: + current_membership = _get_active_membership(request) + business = current_membership.business + + if request.method == 'POST': + form = JobIntakeForm(request.POST, request.FILES, business=business) + if form.is_valid(): + customer = Customer.objects.create( + business=business, + full_name=form.cleaned_data['customer_name'], + email=form.cleaned_data['customer_email'], + phone=form.cleaned_data['customer_phone'], + city=form.cleaned_data['customer_city'], + state=form.cleaned_data['customer_state'], + ) + job = Job.objects.create( + business=business, + customer=customer, + service_type=form.cleaned_data['service_type'], + description=form.cleaned_data['description'], + technician_name=form.cleaned_data['technician_name'], + city=form.cleaned_data['customer_city'], + state=form.cleaned_data['customer_state'], + completed_at=form.cleaned_data['completion_date'], + project_value=form.cleaned_data['project_value'], + status=Job.Status.COMPLETED, + ) + for media_type, upload in ( + (JobMedia.MediaType.BEFORE, form.cleaned_data.get('before_photo')), + (JobMedia.MediaType.AFTER, form.cleaned_data.get('after_photo')), + ): + if upload: + JobMedia.objects.create(job=job, media_type=media_type, file=upload) + + display_name = 'Verified homeowner' if form.cleaned_data['anonymize_customer'] else customer.full_name + ProofCard.objects.create( + job=job, + customer_display_name=display_name, + is_anonymized=form.cleaned_data['anonymize_customer'], + attached_widget_label='Homepage proof gallery', + attached_pages='Homepage, Service pages', + status=ProofCard.Status.DRAFT, + ) + + if form.cleaned_data['send_review_request']: + _create_review_request(request, job, form.cleaned_data['review_channel']) + + messages.success(request, 'Job logged inside your workspace. Proof card drafted and ready for review workflow.') + return redirect('job_detail', job_id=job.id) + else: + form = JobIntakeForm( + business=business, + initial={ + 'business': business, + 'customer_city': business.primary_city, + 'customer_state': business.primary_state, + 'technician_name': request.user.get_full_name(), + }, + ) + + context = {**_theme_context(), 'form': form, 'current_membership': current_membership} + return render(request, 'core/job_form.html', context) + + +@login_required +@business_required +@transaction.atomic +def job_detail(request: HttpRequest, job_id: int) -> HttpResponse: + current_membership = _get_active_membership(request) + job = get_object_or_404( + Job.objects.select_related('customer', 'business', 'proof_card', 'review_request').prefetch_related('media'), + id=job_id, + business=current_membership.business, + ) + if request.method == 'POST' and request.POST.get('action') == 'send_review_request': + channel = request.POST.get('channel', ReviewRequest.Channel.EMAIL) + review_request = _create_review_request(request, job, channel) + messages.success(request, f'Review request sent. Share link: {_build_review_link(request, review_request)}') + return redirect('job_detail', job_id=job.id) + + context = {**_theme_context(), 'job': job, 'current_membership': current_membership} + return render(request, 'core/job_detail.html', context) + + +@login_required +@business_required +@transaction.atomic +def proof_cards_list(request: HttpRequest) -> HttpResponse: + current_membership = _get_active_membership(request) + proof_cards = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter( + job__business=current_membership.business + ) + context = {**_theme_context(), 'proof_cards': proof_cards, 'current_membership': current_membership} + return render(request, 'core/proof_cards_list.html', context) + + +@login_required +@business_required +@transaction.atomic +def proof_card_detail(request: HttpRequest, card_id: int) -> HttpResponse: + current_membership = _get_active_membership(request) + proof_card = get_object_or_404( + ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'), + id=card_id, + job__business=current_membership.business, + ) + if request.method == 'POST': + if not current_membership.can_manage_proof: + raise PermissionDenied('Your role does not allow proof publishing controls.') + action = request.POST.get('action') + if action == 'publish': + proof_card.status = ProofCard.Status.PUBLISHED + proof_card.published_at = timezone.now() + proof_card.save(update_fields=['status', 'published_at', 'updated_at']) + proof_card.job.status = Job.Status.PROOF_READY + proof_card.job.save(update_fields=['status']) + messages.success(request, 'Proof card published to the trust gallery.') + elif action == 'hide': + proof_card.status = ProofCard.Status.HIDDEN + proof_card.save(update_fields=['status', 'updated_at']) + messages.success(request, 'Proof card hidden from public display.') + elif action == 'toggle_featured': + proof_card.is_featured = not proof_card.is_featured + proof_card.save(update_fields=['is_featured', 'updated_at']) + messages.success(request, 'Featured flag updated.') + return redirect('proof_card_detail', card_id=proof_card.id) + + context = {**_theme_context(), 'proof_card': proof_card, 'current_membership': current_membership} + return render(request, 'core/proof_card_detail.html', context) + + +@login_required +@membership_role_required(BusinessMembership.Role.OWNER, BusinessMembership.Role.ADMIN, BusinessMembership.Role.MANAGER) +@transaction.atomic +def proof_card_edit(request: HttpRequest, card_id: int) -> HttpResponse: + current_membership = _get_active_membership(request) + proof_card = get_object_or_404( + ProofCard.objects.select_related('job__customer', 'job__business'), + id=card_id, + job__business=current_membership.business, + ) + if request.method == 'POST': + form = ProofCardForm(request.POST, instance=proof_card) + if form.is_valid(): + proof_card = form.save(commit=False) + if proof_card.status == ProofCard.Status.PUBLISHED and not proof_card.published_at: + proof_card.published_at = timezone.now() + proof_card.save() + messages.success(request, 'Proof card updated.') + return redirect('proof_card_detail', card_id=proof_card.id) + else: + form = ProofCardForm(instance=proof_card) + + context = {**_theme_context(), 'form': form, 'proof_card': proof_card, 'current_membership': current_membership} + return render(request, 'core/proof_card_form.html', context) + + +@transaction.atomic +def review_request_view(request: HttpRequest, token: str) -> HttpResponse: + review_request = get_object_or_404( + ReviewRequest.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'), + token=token, + ) + proof_card = review_request.job.proof_card + if review_request.status == ReviewRequest.Status.SENT: + review_request.status = ReviewRequest.Status.VIEWED + review_request.last_opened_at = timezone.now() + review_request.save(update_fields=['status', 'last_opened_at']) + + submitted = False + positive = False + redirect_url = review_request.job.business.google_review_url + + if request.method == 'POST': + form = PublicFeedbackForm(request.POST) + if form.is_valid(): + experience = form.cleaned_data['experience'] + testimonial = form.cleaned_data['testimonial'].strip() + positive = experience in POSITIVE_EXPERIENCES + feedback, _ = Feedback.objects.update_or_create( + review_request=review_request, + defaults={ + 'experience': experience, + 'rating': RATING_MAP[experience], + 'testimonial': testimonial, + 'follow_up_required': not positive, + 'is_public_approved': positive, + }, + ) + review_request.status = ReviewRequest.Status.RESPONDED + review_request.reviewed_at = timezone.now() + review_request.save(update_fields=['status', 'reviewed_at']) + + proof_card.rating = feedback.rating + proof_card.testimonial_quote = testimonial + if positive: + proof_card.status = ProofCard.Status.PUBLISHED + proof_card.published_at = timezone.now() + else: + proof_card.status = ProofCard.Status.DRAFT + proof_card.save(update_fields=['rating', 'testimonial_quote', 'status', 'published_at', 'updated_at']) + review_request.job.status = Job.Status.PROOF_READY if positive else Job.Status.REVIEW_REQUESTED + review_request.job.save(update_fields=['status']) + submitted = True + form = PublicFeedbackForm() + else: + form = PublicFeedbackForm() + + context = { + **_theme_context(), + 'review_request': review_request, + 'job': review_request.job, + 'proof_card': proof_card, + 'form': form, + 'submitted': submitted, + 'positive': positive, + 'redirect_url': redirect_url, + } + return render(request, 'core/review_request.html', context) diff --git a/static/css/custom.css b/static/css/custom.css index 925f6ed..7b80ec6 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -1,4 +1,805 @@ -/* Custom styles for the application */ -body { - font-family: system-ui, -apple-system, sans-serif; +/* TrustForge design system */ +:root { + --tf-bg: #f4f7f6; + --tf-surface: rgba(255, 255, 255, 0.82); + --tf-surface-strong: #ffffff; + --tf-surface-dark: #0f172a; + --tf-border: rgba(15, 23, 42, 0.08); + --tf-primary: #0f766e; + --tf-primary-deep: #115e59; + --tf-secondary: #1e293b; + --tf-accent: #f97316; + --tf-accent-soft: #fff1e8; + --tf-success: #15803d; + --tf-text: #0f172a; + --tf-muted: #64748b; + --tf-shadow: 0 20px 60px rgba(15, 23, 42, 0.10); + --tf-shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08); + --tf-radius-xl: 28px; + --tf-radius-lg: 22px; + --tf-radius-md: 16px; + --tf-spacing: 1.5rem; +} + +html { + scroll-behavior: smooth; +} + +body.trustforge-body { + font-family: 'Inter', system-ui, sans-serif; + color: var(--tf-text); + background: + radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 35%), + radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 28%), + linear-gradient(180deg, #fbfcfb 0%, var(--tf-bg) 100%); + min-height: 100vh; + position: relative; +} + +h1, h2, h3, h4, h5, .navbar-brand, .tf-display, .tf-section-title, .tf-page-title { + font-family: 'Space Grotesk', 'Inter', sans-serif; + letter-spacing: -0.03em; +} + +a { + color: inherit; + text-decoration: none; +} + +a:hover { + color: inherit; +} + +.tf-background-glow { + position: fixed; + border-radius: 999px; + filter: blur(60px); + pointer-events: none; + z-index: 0; + opacity: 0.85; +} + +.tf-background-glow-1 { + width: 320px; + height: 320px; + background: rgba(15, 118, 110, 0.12); + top: 10%; + left: -4%; +} + +.tf-background-glow-2 { + width: 360px; + height: 360px; + background: rgba(249, 115, 22, 0.10); + right: -6%; + top: 12%; +} + +main, .tf-site-header, .tf-footer { + position: relative; + z-index: 1; +} + +.py-lg-6 { + padding-top: 5rem !important; + padding-bottom: 5rem !important; +} + +.tf-navbar { + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + border-bottom: 1px solid rgba(255, 255, 255, 0.68); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4); +} + +.tf-brand { + display: inline-flex; + align-items: center; + gap: 0.8rem; + font-weight: 700; + color: var(--tf-secondary); +} + +.tf-brand-mark { + width: 42px; + height: 42px; + border-radius: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--tf-primary), #2dd4bf); + color: white; + box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28); +} + +.tf-nav-toggle { + border: 0; +} + +.tf-navbar .nav-link { + color: var(--tf-muted); + font-weight: 600; + padding: 0.6rem 0.95rem; + border-radius: 999px; +} + +.tf-navbar .nav-link:hover, +.tf-navbar .nav-link:focus { + background: rgba(15, 118, 110, 0.08); + color: var(--tf-primary-deep); +} + +.tf-btn { + border-radius: 999px; + padding: 0.85rem 1.35rem; + font-weight: 700; + border: 0; + box-shadow: var(--tf-shadow-soft); + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.tf-btn:hover, +.tf-btn:focus { + transform: translateY(-1px); + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12); +} + +.tf-btn-primary { + background: linear-gradient(135deg, var(--tf-primary), #14b8a6); + color: #fff; +} + +.tf-btn-primary:hover, +.tf-btn-primary:focus { + color: #fff; +} + +.tf-btn-secondary { + background: rgba(255, 255, 255, 0.72); + color: var(--tf-secondary); + border: 1px solid rgba(15, 23, 42, 0.08); +} + +.tf-alert { + border-radius: 18px; + border: 1px solid rgba(15, 118, 110, 0.12); + background: rgba(236, 253, 245, 0.88); + color: var(--tf-primary-deep); + box-shadow: var(--tf-shadow-soft); +} + +.tf-hero-section { + overflow: hidden; +} + +.tf-eyebrow { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border-radius: 999px; + background: rgba(15, 118, 110, 0.08); + color: var(--tf-primary-deep); + padding: 0.5rem 0.95rem; + font-size: 0.82rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.tf-eyebrow-light { + background: rgba(255, 255, 255, 0.08); + color: #cbd5e1; +} + +.tf-display { + font-size: clamp(2.75rem, 7vw, 5.2rem); + line-height: 0.98; + max-width: 13ch; +} + +.tf-lead, +.tf-page-subtitle { + color: var(--tf-muted); + font-size: 1.08rem; + line-height: 1.75; + max-width: 62ch; +} + +.tf-page-title { + font-size: clamp(2rem, 4vw, 3.3rem); + margin-bottom: 0.75rem; +} + +.tf-section-title { + font-size: clamp(1.75rem, 3.2vw, 2.6rem); + margin-bottom: 0; +} + +.tf-stat-row span, +.tf-metric-card span { + display: block; + font-family: 'Space Grotesk', sans-serif; + font-size: 2rem; + line-height: 1; + color: var(--tf-secondary); + margin-bottom: 0.35rem; +} + +.tf-stat-chip, +.tf-metric-card { + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: var(--tf-radius-md); + padding: 1.1rem 1rem; + box-shadow: var(--tf-shadow-soft); + color: var(--tf-muted); +} + +.tf-device-card, +.tf-panel, +.tf-proof-card, +.tf-empty-state { + background: var(--tf-surface); + backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.75); + border-radius: var(--tf-radius-xl); + box-shadow: var(--tf-shadow); +} + +.tf-device-card { + padding: 1.25rem; + position: relative; + overflow: hidden; +} + +.tf-device-card::after { + content: ''; + position: absolute; + width: 160px; + height: 160px; + background: linear-gradient(135deg, rgba(249, 115, 22, 0.18), transparent); + border-radius: 36px; + right: -30px; + bottom: -40px; + transform: rotate(18deg); +} + +.tf-device-header { + display: flex; + gap: 0.45rem; + margin-bottom: 1rem; +} + +.tf-device-header span { + width: 12px; + height: 12px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.16); +} + +.tf-device-body { + position: relative; + z-index: 1; +} + +.tf-mini-step, +.tf-activity-row, +.tf-proof-mini { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1rem 1.1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.tf-mini-step.active { + color: var(--tf-secondary); + font-weight: 700; +} + +.tf-proof-preview, +.tf-panel { + padding: 1.5rem; +} + +.tf-panel-dark { + background: linear-gradient(135deg, #0f172a, #1e293b); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.tf-panel-icon { + width: 52px; + height: 52px; + border-radius: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(15, 118, 110, 0.10); + margin-bottom: 1rem; + font-size: 1.25rem; +} + +.tf-proof-media-grid, +.tf-proof-media-grid-large, +.tf-proof-media-grid-static { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.tf-proof-media-grid-static { + grid-template-columns: 1fr; +} + +.tf-photo-slot { + min-height: 170px; + border-radius: 20px; + background: + linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0)), + linear-gradient(135deg, rgba(15, 23, 42, 0.88), rgba(15, 118, 110, 0.82)); + color: rgba(255,255,255,0.92); + font-weight: 700; + display: flex; + align-items: end; + justify-content: start; + padding: 1rem; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.10); +} + +.tf-photo-slot-after { + background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86)); +} + +.tf-empty-proof { + border-radius: 22px; + background: rgba(248, 250, 252, 0.72); + padding: 1rem; +} + +.tf-proof-card-link { + display: block; + height: 100%; +} + +.tf-proof-card { + overflow: hidden; +} + +.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot { + min-height: 240px; +} + +.tf-proof-card-body { + padding: 1.35rem; +} + +.tf-card-tag { + display: inline-flex; + align-items: center; + padding: 0.45rem 0.8rem; + border-radius: 999px; + background: rgba(15, 118, 110, 0.08); + color: var(--tf-primary-deep); + font-size: 0.85rem; + font-weight: 700; +} + +.tf-badge-verified, +.tf-rating-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.42rem 0.75rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 800; +} + +.tf-badge-verified { + background: rgba(21, 128, 61, 0.10); + color: var(--tf-success); +} + +.tf-rating-pill { + background: var(--tf-accent-soft); + color: var(--tf-accent); +} + +.tf-status-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border-radius: 999px; + padding: 0.4rem 0.7rem; + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tf-status-completed, +.tf-status-draft { + background: rgba(15, 23, 42, 0.08); + color: var(--tf-secondary); +} + +.tf-status-review_requested, +.tf-status-sent, +.tf-status-viewed { + background: rgba(249, 115, 22, 0.12); + color: var(--tf-accent); +} + +.tf-status-proof_ready, +.tf-status-published, +.tf-status-responded { + background: rgba(21, 128, 61, 0.12); + color: var(--tf-success); +} + +.tf-status-hidden { + background: rgba(100, 116, 139, 0.12); + color: var(--tf-muted); +} + +.tf-inline-link { + color: var(--tf-primary-deep); + font-weight: 700; +} + +.tf-inline-link:hover, +.tf-inline-link:focus { + color: var(--tf-primary); +} + +.tf-activity-row-soft { + background: rgba(248, 250, 252, 0.9); +} + +.tf-activity-link, +.tf-proof-mini { + color: inherit; +} + +.tf-proof-mini { + text-decoration: none; +} + +.tf-table thead th { + border-bottom-width: 0; + color: var(--tf-muted); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 1.1rem 1.25rem; +} + +.tf-table tbody td { + padding: 1.1rem 1.25rem; + border-color: rgba(15, 23, 42, 0.06); +} + +.tf-detail-box { + background: rgba(248, 250, 252, 0.92); + border-radius: 18px; + padding: 1rem; + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.tf-detail-box span { + display: block; + color: var(--tf-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 0.35rem; +} + +.tf-detail-box strong { + display: block; + font-size: 1rem; +} + +.tf-check-row { + display: flex; + gap: 0.9rem; + align-items: center; + font-weight: 600; +} + +.tf-check-row span { + width: 34px; + height: 34px; + border-radius: 12px; + background: rgba(15, 118, 110, 0.12); + color: var(--tf-primary-deep); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 800; +} + +.tf-check-field { + background: rgba(248, 250, 252, 0.92); + padding: 1rem; + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.tf-sticky-panel { + top: 6rem; +} + +.tf-feedback-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.tf-feedback-option { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 96px; + border-radius: 22px; + background: rgba(248, 250, 252, 0.92); + border: 1px solid rgba(15, 23, 42, 0.06); + font-weight: 700; + cursor: pointer; +} + +.tf-feedback-option input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.tf-feedback-option:has(input:checked) { + border-color: rgba(15, 118, 110, 0.55); + background: rgba(236, 253, 245, 0.95); + color: var(--tf-primary-deep); + box-shadow: 0 12px 28px rgba(15, 118, 110, 0.14); +} + +.tf-testimonial { + font-size: 1.1rem; + line-height: 1.8; + color: var(--tf-secondary); + border-left: 4px solid rgba(15, 118, 110, 0.22); + padding-left: 1rem; +} + +.tf-panel-centered { + padding: 2rem; +} + +.tf-footer { + border-top: 1px solid rgba(15, 23, 42, 0.06); +} + +.tf-footer-brand { + font-family: 'Space Grotesk', sans-serif; + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 0.35rem; +} + +.form-control, +.form-select { + min-height: 50px; + border-radius: 16px; + border-color: rgba(15, 23, 42, 0.10); + box-shadow: none; +} + +textarea.form-control { + min-height: 120px; +} + +.form-control:focus, +.form-select:focus, +.form-check-input:focus, +.btn:focus { + border-color: rgba(15, 118, 110, 0.4); + box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.15); +} + +@media (max-width: 991.98px) { + .tf-display { + max-width: 100%; + } + + .tf-feedback-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 767.98px) { + .tf-device-card, + .tf-panel, + .tf-proof-card, + .tf-empty-state { + border-radius: 22px; + } + + .tf-proof-media-grid, + .tf-proof-media-grid-large { + grid-template-columns: 1fr; + } + + .tf-photo-slot, + .tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot { + min-height: 180px; + } +} + + +.tf-user-menu { + border: 1px solid rgba(15, 23, 42, 0.06); + background: rgba(255, 255, 255, 0.78); + border-radius: 999px; + padding: 0.8rem 1rem; + box-shadow: var(--tf-shadow-soft); + color: var(--tf-secondary); + font-weight: 700; +} + +.tf-user-menu:hover, +.tf-user-menu:focus { + color: var(--tf-primary-deep); +} + +.tf-user-dropdown { + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: var(--tf-shadow-soft); + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.tf-user-dropdown .dropdown-item { + font-weight: 600; + color: var(--tf-secondary); + border-radius: 12px; +} + +.tf-user-dropdown .dropdown-item:hover, +.tf-user-dropdown .dropdown-item:focus, +.tf-logout-link:hover, +.tf-logout-link:focus { + background: rgba(15, 118, 110, 0.08); + color: var(--tf-primary-deep); +} + +.tf-logout-link { + background: transparent; + border: 0; + width: 100%; + text-align: left; + padding: 0.5rem 0.75rem; +} + +.tf-auth-card { + max-width: 720px; + margin: 0 auto; +} + +.tf-auth-points { + display: grid; + gap: 0.9rem; +} + +@media (max-width: 991.98px) { + .tf-user-menu { + width: 100%; + justify-content: center; + } +} + + +.tf-workspace-chip { + display: inline-flex; + align-items: center; + gap: 0.85rem; + padding: 0.55rem 0.9rem; + border-radius: 999px; + background: rgba(15, 118, 110, 0.12); + border: 1px solid rgba(15, 118, 110, 0.16); + color: #0f3f3b; +} + +.tf-workspace-chip strong, +.tf-dropdown-label strong { + display: block; + font-size: 0.92rem; +} + +.tf-workspace-chip small, +.tf-dropdown-label span { + display: block; + color: rgba(30, 41, 59, 0.72); + font-size: 0.75rem; +} + +.tf-workspace-chip-mark { + width: 2rem; + height: 2rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #0f766e, #14b8a6); + color: white; + font-size: 0.8rem; + font-weight: 700; +} + +.tf-dropdown-label, +.tf-dropdown-section { + color: #0f172a; + font-size: 0.82rem; +} + +.tf-workspace-switch { + border-radius: 0.85rem; +} + +.tf-workspace-switch small { + display: block; + color: rgba(30, 41, 59, 0.7); +} + +.tf-workspace-switch.active { + background: rgba(15, 118, 110, 0.08); +} + +.tf-inline-stat { + padding: 0.9rem 1rem; + border-radius: 1rem; + background: rgba(15, 118, 110, 0.08); + border: 1px solid rgba(15, 118, 110, 0.15); +} + +.tf-inline-stat span { + display: block; + font-size: 0.78rem; + color: rgba(30, 41, 59, 0.7); +} + +.tf-inline-stat strong { + display: block; + color: #0f172a; +} + +.tf-role-card, +.tf-team-member { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.1rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(15, 118, 110, 0.12); +} + +.tf-role-card { + flex-direction: column; +} + +.tf-role-card span { + color: rgba(30, 41, 59, 0.76); +} + +.tf-team-member-active { + box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.18); +} + +.tf-role-pill { + background: rgba(249, 115, 22, 0.14); + color: #9a3412; } diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 108056f..7b80ec6 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -1,21 +1,805 @@ - +/* TrustForge design system */ :root { - --bg-color-start: #6a11cb; - --bg-color-end: #2575fc; - --text-color: #ffffff; - --card-bg-color: rgba(255, 255, 255, 0.01); - --card-border-color: rgba(255, 255, 255, 0.1); + --tf-bg: #f4f7f6; + --tf-surface: rgba(255, 255, 255, 0.82); + --tf-surface-strong: #ffffff; + --tf-surface-dark: #0f172a; + --tf-border: rgba(15, 23, 42, 0.08); + --tf-primary: #0f766e; + --tf-primary-deep: #115e59; + --tf-secondary: #1e293b; + --tf-accent: #f97316; + --tf-accent-soft: #fff1e8; + --tf-success: #15803d; + --tf-text: #0f172a; + --tf-muted: #64748b; + --tf-shadow: 0 20px 60px rgba(15, 23, 42, 0.10); + --tf-shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08); + --tf-radius-xl: 28px; + --tf-radius-lg: 22px; + --tf-radius-md: 16px; + --tf-spacing: 1.5rem; } -body { - margin: 0; - font-family: 'Inter', sans-serif; - background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end)); - color: var(--text-color); - display: flex; - justify-content: center; - align-items: center; + +html { + scroll-behavior: smooth; +} + +body.trustforge-body { + font-family: 'Inter', system-ui, sans-serif; + color: var(--tf-text); + background: + radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 35%), + radial-gradient(circle at top right, rgba(249, 115, 22, 0.12), transparent 28%), + linear-gradient(180deg, #fbfcfb 0%, var(--tf-bg) 100%); min-height: 100vh; - text-align: center; - overflow: hidden; position: relative; } + +h1, h2, h3, h4, h5, .navbar-brand, .tf-display, .tf-section-title, .tf-page-title { + font-family: 'Space Grotesk', 'Inter', sans-serif; + letter-spacing: -0.03em; +} + +a { + color: inherit; + text-decoration: none; +} + +a:hover { + color: inherit; +} + +.tf-background-glow { + position: fixed; + border-radius: 999px; + filter: blur(60px); + pointer-events: none; + z-index: 0; + opacity: 0.85; +} + +.tf-background-glow-1 { + width: 320px; + height: 320px; + background: rgba(15, 118, 110, 0.12); + top: 10%; + left: -4%; +} + +.tf-background-glow-2 { + width: 360px; + height: 360px; + background: rgba(249, 115, 22, 0.10); + right: -6%; + top: 12%; +} + +main, .tf-site-header, .tf-footer { + position: relative; + z-index: 1; +} + +.py-lg-6 { + padding-top: 5rem !important; + padding-bottom: 5rem !important; +} + +.tf-navbar { + background: rgba(255, 255, 255, 0.74); + backdrop-filter: blur(18px); + border-bottom: 1px solid rgba(255, 255, 255, 0.68); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4); +} + +.tf-brand { + display: inline-flex; + align-items: center; + gap: 0.8rem; + font-weight: 700; + color: var(--tf-secondary); +} + +.tf-brand-mark { + width: 42px; + height: 42px; + border-radius: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--tf-primary), #2dd4bf); + color: white; + box-shadow: 0 12px 30px rgba(15, 118, 110, 0.28); +} + +.tf-nav-toggle { + border: 0; +} + +.tf-navbar .nav-link { + color: var(--tf-muted); + font-weight: 600; + padding: 0.6rem 0.95rem; + border-radius: 999px; +} + +.tf-navbar .nav-link:hover, +.tf-navbar .nav-link:focus { + background: rgba(15, 118, 110, 0.08); + color: var(--tf-primary-deep); +} + +.tf-btn { + border-radius: 999px; + padding: 0.85rem 1.35rem; + font-weight: 700; + border: 0; + box-shadow: var(--tf-shadow-soft); + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +.tf-btn:hover, +.tf-btn:focus { + transform: translateY(-1px); + box-shadow: 0 16px 32px rgba(15, 23, 42, 0.12); +} + +.tf-btn-primary { + background: linear-gradient(135deg, var(--tf-primary), #14b8a6); + color: #fff; +} + +.tf-btn-primary:hover, +.tf-btn-primary:focus { + color: #fff; +} + +.tf-btn-secondary { + background: rgba(255, 255, 255, 0.72); + color: var(--tf-secondary); + border: 1px solid rgba(15, 23, 42, 0.08); +} + +.tf-alert { + border-radius: 18px; + border: 1px solid rgba(15, 118, 110, 0.12); + background: rgba(236, 253, 245, 0.88); + color: var(--tf-primary-deep); + box-shadow: var(--tf-shadow-soft); +} + +.tf-hero-section { + overflow: hidden; +} + +.tf-eyebrow { + display: inline-flex; + align-items: center; + gap: 0.5rem; + border-radius: 999px; + background: rgba(15, 118, 110, 0.08); + color: var(--tf-primary-deep); + padding: 0.5rem 0.95rem; + font-size: 0.82rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.tf-eyebrow-light { + background: rgba(255, 255, 255, 0.08); + color: #cbd5e1; +} + +.tf-display { + font-size: clamp(2.75rem, 7vw, 5.2rem); + line-height: 0.98; + max-width: 13ch; +} + +.tf-lead, +.tf-page-subtitle { + color: var(--tf-muted); + font-size: 1.08rem; + line-height: 1.75; + max-width: 62ch; +} + +.tf-page-title { + font-size: clamp(2rem, 4vw, 3.3rem); + margin-bottom: 0.75rem; +} + +.tf-section-title { + font-size: clamp(1.75rem, 3.2vw, 2.6rem); + margin-bottom: 0; +} + +.tf-stat-row span, +.tf-metric-card span { + display: block; + font-family: 'Space Grotesk', sans-serif; + font-size: 2rem; + line-height: 1; + color: var(--tf-secondary); + margin-bottom: 0.35rem; +} + +.tf-stat-chip, +.tf-metric-card { + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(255, 255, 255, 0.7); + border-radius: var(--tf-radius-md); + padding: 1.1rem 1rem; + box-shadow: var(--tf-shadow-soft); + color: var(--tf-muted); +} + +.tf-device-card, +.tf-panel, +.tf-proof-card, +.tf-empty-state { + background: var(--tf-surface); + backdrop-filter: blur(18px); + border: 1px solid rgba(255, 255, 255, 0.75); + border-radius: var(--tf-radius-xl); + box-shadow: var(--tf-shadow); +} + +.tf-device-card { + padding: 1.25rem; + position: relative; + overflow: hidden; +} + +.tf-device-card::after { + content: ''; + position: absolute; + width: 160px; + height: 160px; + background: linear-gradient(135deg, rgba(249, 115, 22, 0.18), transparent); + border-radius: 36px; + right: -30px; + bottom: -40px; + transform: rotate(18deg); +} + +.tf-device-header { + display: flex; + gap: 0.45rem; + margin-bottom: 1rem; +} + +.tf-device-header span { + width: 12px; + height: 12px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.16); +} + +.tf-device-body { + position: relative; + z-index: 1; +} + +.tf-mini-step, +.tf-activity-row, +.tf-proof-mini { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1rem 1.1rem; + border-radius: 18px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.tf-mini-step.active { + color: var(--tf-secondary); + font-weight: 700; +} + +.tf-proof-preview, +.tf-panel { + padding: 1.5rem; +} + +.tf-panel-dark { + background: linear-gradient(135deg, #0f172a, #1e293b); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.tf-panel-icon { + width: 52px; + height: 52px; + border-radius: 18px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(15, 118, 110, 0.10); + margin-bottom: 1rem; + font-size: 1.25rem; +} + +.tf-proof-media-grid, +.tf-proof-media-grid-large, +.tf-proof-media-grid-static { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.tf-proof-media-grid-static { + grid-template-columns: 1fr; +} + +.tf-photo-slot { + min-height: 170px; + border-radius: 20px; + background: + linear-gradient(180deg, rgba(255,255,255,0.08), rgba(255,255,255,0)), + linear-gradient(135deg, rgba(15, 23, 42, 0.88), rgba(15, 118, 110, 0.82)); + color: rgba(255,255,255,0.92); + font-weight: 700; + display: flex; + align-items: end; + justify-content: start; + padding: 1rem; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.10); +} + +.tf-photo-slot-after { + background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86)); +} + +.tf-empty-proof { + border-radius: 22px; + background: rgba(248, 250, 252, 0.72); + padding: 1rem; +} + +.tf-proof-card-link { + display: block; + height: 100%; +} + +.tf-proof-card { + overflow: hidden; +} + +.tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot { + min-height: 240px; +} + +.tf-proof-card-body { + padding: 1.35rem; +} + +.tf-card-tag { + display: inline-flex; + align-items: center; + padding: 0.45rem 0.8rem; + border-radius: 999px; + background: rgba(15, 118, 110, 0.08); + color: var(--tf-primary-deep); + font-size: 0.85rem; + font-weight: 700; +} + +.tf-badge-verified, +.tf-rating-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.42rem 0.75rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 800; +} + +.tf-badge-verified { + background: rgba(21, 128, 61, 0.10); + color: var(--tf-success); +} + +.tf-rating-pill { + background: var(--tf-accent-soft); + color: var(--tf-accent); +} + +.tf-status-pill { + display: inline-flex; + align-items: center; + gap: 0.4rem; + border-radius: 999px; + padding: 0.4rem 0.7rem; + font-size: 0.78rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.tf-status-completed, +.tf-status-draft { + background: rgba(15, 23, 42, 0.08); + color: var(--tf-secondary); +} + +.tf-status-review_requested, +.tf-status-sent, +.tf-status-viewed { + background: rgba(249, 115, 22, 0.12); + color: var(--tf-accent); +} + +.tf-status-proof_ready, +.tf-status-published, +.tf-status-responded { + background: rgba(21, 128, 61, 0.12); + color: var(--tf-success); +} + +.tf-status-hidden { + background: rgba(100, 116, 139, 0.12); + color: var(--tf-muted); +} + +.tf-inline-link { + color: var(--tf-primary-deep); + font-weight: 700; +} + +.tf-inline-link:hover, +.tf-inline-link:focus { + color: var(--tf-primary); +} + +.tf-activity-row-soft { + background: rgba(248, 250, 252, 0.9); +} + +.tf-activity-link, +.tf-proof-mini { + color: inherit; +} + +.tf-proof-mini { + text-decoration: none; +} + +.tf-table thead th { + border-bottom-width: 0; + color: var(--tf-muted); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 1.1rem 1.25rem; +} + +.tf-table tbody td { + padding: 1.1rem 1.25rem; + border-color: rgba(15, 23, 42, 0.06); +} + +.tf-detail-box { + background: rgba(248, 250, 252, 0.92); + border-radius: 18px; + padding: 1rem; + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.tf-detail-box span { + display: block; + color: var(--tf-muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 0.35rem; +} + +.tf-detail-box strong { + display: block; + font-size: 1rem; +} + +.tf-check-row { + display: flex; + gap: 0.9rem; + align-items: center; + font-weight: 600; +} + +.tf-check-row span { + width: 34px; + height: 34px; + border-radius: 12px; + background: rgba(15, 118, 110, 0.12); + color: var(--tf-primary-deep); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 800; +} + +.tf-check-field { + background: rgba(248, 250, 252, 0.92); + padding: 1rem; + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.06); +} + +.tf-sticky-panel { + top: 6rem; +} + +.tf-feedback-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.tf-feedback-option { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 96px; + border-radius: 22px; + background: rgba(248, 250, 252, 0.92); + border: 1px solid rgba(15, 23, 42, 0.06); + font-weight: 700; + cursor: pointer; +} + +.tf-feedback-option input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.tf-feedback-option:has(input:checked) { + border-color: rgba(15, 118, 110, 0.55); + background: rgba(236, 253, 245, 0.95); + color: var(--tf-primary-deep); + box-shadow: 0 12px 28px rgba(15, 118, 110, 0.14); +} + +.tf-testimonial { + font-size: 1.1rem; + line-height: 1.8; + color: var(--tf-secondary); + border-left: 4px solid rgba(15, 118, 110, 0.22); + padding-left: 1rem; +} + +.tf-panel-centered { + padding: 2rem; +} + +.tf-footer { + border-top: 1px solid rgba(15, 23, 42, 0.06); +} + +.tf-footer-brand { + font-family: 'Space Grotesk', sans-serif; + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 0.35rem; +} + +.form-control, +.form-select { + min-height: 50px; + border-radius: 16px; + border-color: rgba(15, 23, 42, 0.10); + box-shadow: none; +} + +textarea.form-control { + min-height: 120px; +} + +.form-control:focus, +.form-select:focus, +.form-check-input:focus, +.btn:focus { + border-color: rgba(15, 118, 110, 0.4); + box-shadow: 0 0 0 0.25rem rgba(15, 118, 110, 0.15); +} + +@media (max-width: 991.98px) { + .tf-display { + max-width: 100%; + } + + .tf-feedback-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 767.98px) { + .tf-device-card, + .tf-panel, + .tf-proof-card, + .tf-empty-state { + border-radius: 22px; + } + + .tf-proof-media-grid, + .tf-proof-media-grid-large { + grid-template-columns: 1fr; + } + + .tf-photo-slot, + .tf-proof-card-large .tf-proof-media-grid-large .tf-photo-slot { + min-height: 180px; + } +} + + +.tf-user-menu { + border: 1px solid rgba(15, 23, 42, 0.06); + background: rgba(255, 255, 255, 0.78); + border-radius: 999px; + padding: 0.8rem 1rem; + box-shadow: var(--tf-shadow-soft); + color: var(--tf-secondary); + font-weight: 700; +} + +.tf-user-menu:hover, +.tf-user-menu:focus { + color: var(--tf-primary-deep); +} + +.tf-user-dropdown { + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.08); + box-shadow: var(--tf-shadow-soft); + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.tf-user-dropdown .dropdown-item { + font-weight: 600; + color: var(--tf-secondary); + border-radius: 12px; +} + +.tf-user-dropdown .dropdown-item:hover, +.tf-user-dropdown .dropdown-item:focus, +.tf-logout-link:hover, +.tf-logout-link:focus { + background: rgba(15, 118, 110, 0.08); + color: var(--tf-primary-deep); +} + +.tf-logout-link { + background: transparent; + border: 0; + width: 100%; + text-align: left; + padding: 0.5rem 0.75rem; +} + +.tf-auth-card { + max-width: 720px; + margin: 0 auto; +} + +.tf-auth-points { + display: grid; + gap: 0.9rem; +} + +@media (max-width: 991.98px) { + .tf-user-menu { + width: 100%; + justify-content: center; + } +} + + +.tf-workspace-chip { + display: inline-flex; + align-items: center; + gap: 0.85rem; + padding: 0.55rem 0.9rem; + border-radius: 999px; + background: rgba(15, 118, 110, 0.12); + border: 1px solid rgba(15, 118, 110, 0.16); + color: #0f3f3b; +} + +.tf-workspace-chip strong, +.tf-dropdown-label strong { + display: block; + font-size: 0.92rem; +} + +.tf-workspace-chip small, +.tf-dropdown-label span { + display: block; + color: rgba(30, 41, 59, 0.72); + font-size: 0.75rem; +} + +.tf-workspace-chip-mark { + width: 2rem; + height: 2rem; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #0f766e, #14b8a6); + color: white; + font-size: 0.8rem; + font-weight: 700; +} + +.tf-dropdown-label, +.tf-dropdown-section { + color: #0f172a; + font-size: 0.82rem; +} + +.tf-workspace-switch { + border-radius: 0.85rem; +} + +.tf-workspace-switch small { + display: block; + color: rgba(30, 41, 59, 0.7); +} + +.tf-workspace-switch.active { + background: rgba(15, 118, 110, 0.08); +} + +.tf-inline-stat { + padding: 0.9rem 1rem; + border-radius: 1rem; + background: rgba(15, 118, 110, 0.08); + border: 1px solid rgba(15, 118, 110, 0.15); +} + +.tf-inline-stat span { + display: block; + font-size: 0.78rem; + color: rgba(30, 41, 59, 0.7); +} + +.tf-inline-stat strong { + display: block; + color: #0f172a; +} + +.tf-role-card, +.tf-team-member { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.1rem; + border-radius: 1rem; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(15, 118, 110, 0.12); +} + +.tf-role-card { + flex-direction: column; +} + +.tf-role-card span { + color: rgba(30, 41, 59, 0.76); +} + +.tf-team-member-active { + box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.18); +} + +.tf-role-pill { + background: rgba(249, 115, 22, 0.14); + color: #9a3412; +}