From 159e91248cc62b81ba1f666b0b24eb24ebde39b7 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 11 Apr 2026 01:49:55 +0000 Subject: [PATCH] 1 --- config/__pycache__/__init__.cpython-311.pyc | Bin 159 -> 159 bytes config/__pycache__/settings.cpython-311.pyc | Bin 5552 -> 5533 bytes config/__pycache__/urls.cpython-311.pyc | Bin 1557 -> 1062 bytes config/__pycache__/wsgi.cpython-311.pyc | Bin 679 -> 679 bytes config/settings.py | 106 +-- config/urls.py | 24 +- core/__pycache__/__init__.cpython-311.pyc | Bin 157 -> 157 bytes core/__pycache__/admin.cpython-311.pyc | Bin 212 -> 3930 bytes core/__pycache__/apps.cpython-311.pyc | Bin 524 -> 524 bytes .../context_processors.cpython-311.pyc | Bin 763 -> 1928 bytes core/__pycache__/forms.cpython-311.pyc | Bin 0 -> 20843 bytes core/__pycache__/models.cpython-311.pyc | Bin 209 -> 17657 bytes core/__pycache__/tests.cpython-311.pyc | Bin 0 -> 3343 bytes core/__pycache__/urls.cpython-311.pyc | Bin 347 -> 2827 bytes core/__pycache__/views.cpython-311.pyc | Bin 1364 -> 39244 bytes core/admin.py | 58 +- core/context_processors.py | 30 +- core/forms.py | 372 ++++++++ core/migrations/0001_initial.py | 143 +++ core/migrations/0002_seed_trustforge_demo.py | 98 +++ core/migrations/0003_businessmembership.py | 31 + .../__pycache__/0001_initial.cpython-311.pyc | Bin 0 -> 8521 bytes .../0002_seed_trustforge_demo.cpython-311.pyc | Bin 0 -> 4000 bytes .../0003_businessmembership.cpython-311.pyc | Bin 0 -> 2083 bytes .../__pycache__/__init__.cpython-311.pyc | Bin 168 -> 168 bytes core/models.py | 216 ++++- core/templates/base.html | 142 ++- core/templates/core/business_onboarding.html | 76 ++ core/templates/core/dashboard.html | 83 ++ core/templates/core/index.html | 328 ++++--- core/templates/core/job_detail.html | 86 ++ core/templates/core/job_form.html | 114 +++ core/templates/core/jobs_list.html | 59 ++ core/templates/core/profile_settings.html | 77 ++ core/templates/core/proof_card_detail.html | 82 ++ core/templates/core/proof_card_form.html | 65 ++ core/templates/core/proof_cards_list.html | 55 ++ core/templates/core/review_request.html | 54 ++ core/templates/core/workspace_settings.html | 132 +++ core/templates/registration/login.html | 54 ++ .../registration/password_reset_complete.html | 21 + .../registration/password_reset_confirm.html | 45 + .../registration/password_reset_done.html | 25 + .../registration/password_reset_email.txt | 10 + .../registration/password_reset_form.html | 33 + .../registration/password_reset_subject.txt | 1 + core/templates/registration/signup.html | 67 ++ core/tests.py | 124 ++- core/urls.py | 44 +- core/views.py | 691 ++++++++++++++- static/css/custom.css | 807 ++++++++++++++++- staticfiles/css/custom.css | 816 +++++++++++++++++- 52 files changed, 4875 insertions(+), 294 deletions(-) create mode 100644 core/__pycache__/forms.cpython-311.pyc create mode 100644 core/__pycache__/tests.cpython-311.pyc create mode 100644 core/forms.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/0002_seed_trustforge_demo.py create mode 100644 core/migrations/0003_businessmembership.py create mode 100644 core/migrations/__pycache__/0001_initial.cpython-311.pyc create mode 100644 core/migrations/__pycache__/0002_seed_trustforge_demo.cpython-311.pyc create mode 100644 core/migrations/__pycache__/0003_businessmembership.cpython-311.pyc create mode 100644 core/templates/core/business_onboarding.html create mode 100644 core/templates/core/dashboard.html create mode 100644 core/templates/core/job_detail.html create mode 100644 core/templates/core/job_form.html create mode 100644 core/templates/core/jobs_list.html create mode 100644 core/templates/core/profile_settings.html create mode 100644 core/templates/core/proof_card_detail.html create mode 100644 core/templates/core/proof_card_form.html create mode 100644 core/templates/core/proof_cards_list.html create mode 100644 core/templates/core/review_request.html create mode 100644 core/templates/core/workspace_settings.html create mode 100644 core/templates/registration/login.html create mode 100644 core/templates/registration/password_reset_complete.html create mode 100644 core/templates/registration/password_reset_confirm.html create mode 100644 core/templates/registration/password_reset_done.html create mode 100644 core/templates/registration/password_reset_email.txt create mode 100644 core/templates/registration/password_reset_form.html create mode 100644 core/templates/registration/password_reset_subject.txt create mode 100644 core/templates/registration/signup.html diff --git a/config/__pycache__/__init__.cpython-311.pyc b/config/__pycache__/__init__.cpython-311.pyc index 896bb4f1b8949c96d2f1bf743293342b70a9d0e4..3a859bf3c9e94528c685a6231638ec7e7fe72270 100644 GIT binary patch delta 19 ZcmbQwIG>SwIWI340}w>dzd4b63IHt$1sVVV delta 19 ZcmbQwIG>SwIWI340}#~tteD6>1pq5}1i1hJ diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index d79d6a79890a58443ee5f65d8d558823a19e9aed..480eac2015a6b466bf99fe5181ba2aa53a531c6a 100644 GIT binary patch delta 952 zcmZ9JO-~a+9L8t%t?YJp`(9h6&;)^CDWDXDBGC#JYbc9-39y%1nMP`OOCgdF6E?;V zAg+mANIV!kc+vD5aMvt}hjkC8S8po^kLqkIMs_mu%(Kt@=0CH$A7Y0w@tq)W0Jq}X zuhnhWz8He1AA5fgduUkhs<0@r>jXf)On?gcxXD>88bPB+1OV^xXf?UpMqEdnjp)Hd zuZu{ISFIxCKXi~7%%nPmT#h4wj<&w z^&Hr!+m-lv>dnB}d;dS9DfKhe**#e4uMuckj-j+Xgzm^!(Tscz-Ia$?Mou6y5Bl!y zE~_-XTpwas2rsBf$8Q*^C)<{3*DkYGLDll0K{x06p|AttQ#If!M+|y(<56|pU{QH% z<>5xTh74+DW35tE?n9tHaYf-s{k7{D?ip-0J)KFFin&>XYuTJ66>155*V+gcgRHJ^ z*G8?!F-yiRNm?>t$s`to-r8KPZr4(lXJ@6E%v`gcw`FtqfLwa2(9C=|lZu7X-!Rfj zgY&6;enHAj<2;`(lnR+_8dK^^&p^i}u4?_`t?wiNCJ7-l2?|msu!OIF^K?SUslUA8 zE~?E-nBYtl(eyh+9Gr>5nO-&NyA-FpL=*fI(&6V!mZupLSVD>dp|EBY_~ROIn~eI* zw^Qr@46g|b8nvj?PEE*a)LhfdX_VA7l19y&EZt6MAY_Wb6H)}7_b9CHbSZ5*D=17u z2r8T|1`i@9Vvpi%P`vuU-_gqfA%HOAa-Tks8Gy`(fITH=lq^uKF{)R^XR3jDuA#1 z*I$`S`WDKsfN+xl@nJrXI-lZCCH^Kdknh~W#RJh}J?L>!AW|9ABBv4fvH z{3e%z&#{5*I30Baa=A1@u9ONyyaPs-%6SMtfThX<<*M!AHQS-sw5qnVR`yKW!hCxx zri-g)jgDcLQf0;S>h7Fj5WC_Q$ZFYIw(E|)N-JKWVy_w0GKz(>hG*B!ifg#k^Gs{m zHI)79y%mb9>$MuLnXZTJD!;JZ$K`7l;YrkV@V#dB!*m%zXQe8hAa@xlRo zg=K=i^s(`H4l(c>VnSD#tV(ePLWn6<@MU%q=;KY$$xuMj8_UB$PBlAAOcb~yu`g~O z7Pk(I+ih`F?Gu_H%Laj#_0?8V^W|1Y%1^Xp)sM3zK;6F5LYb%iAJL%S!Qwztg9-ik z00LU?W8sOs9ZPS=&g@E}CVT@NzRHtPcd)L1J%K<>V*tY5ZN108!Rwh~6Lht=qw!(= E0^gP2MF0Q* diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 8cf22af743397374cabf5fa358c4f52631471db9..317ce2bd52c35edfb36a216e805ab0e42df69382 100644 GIT binary patch delta 410 zcmbQrvy6jpIWI340}wo1a5Gb#X(FElquoSxc{N6c6vh;m9M)X6C^j&gHHSTyBZ?!J zGm4Xufr%lNGleacZDNipJ7)?zkjpV~hO#(Q7B9#s5Ge9X)L^PopKQ&jF8vZDp~-lQqd2vsBr`9)7|3QT zE=erOOrE@#S!(iIW-CeITkP@iDf!9q@wd2BvJ&&s^YxPR^U{i#C+D!pYO??hxW$JM zFDc4QDq;l*!W5Sl6lvII1EgWv~tpkl@k3?Q;d3TO=g&*Ww8 literal 1557 zcmb7Ezi-<{6h2a{19y)mR5TN}F(iZ8^v428G3D6*L?bJ<0woHA;AE;cUKy&0B9v{E=z3;s{^2bKQ zKrp_&|C6_=A@qlQC`KhWUKuLNpWRis_=OM%wa0==uJ&zfHjD&0zI z>wYz;b!!Sz5z*Z;QAcSe$vLVQDPF1wy@HvI6j^~)bE_{Q=PQ`mIEx#o-4#+9HHx=v zi>yFY&8>m=T;X+%G35EBel~UtQ4pQt0liwF!G5o>-yY+x93VMc4f`9AY&`7T#|{nq z-XLbS@Mwtpl;s{AF*+iSFb(5yNbr-G`H?LIVIhBleUA&wXe6`PEz6}2H{FpP4rr0ip@Bt0>zaAh5;XE0Zb`5O z>A7X>KC=Vh;%(zj96G7uG;K=yv5B``7uzBBf`~HF#<Hd?r!x3ZnJE87lJ zHoOp-V>i>L{BN~KJ{?%lz#>yqI#S+dt7ZHtk7`RvblVNQu%#xI9G7Ua_3)<3i4af> z2fNG#oFr#lPSI9^#x(~HS;{u9TCfpBa4RWi1_U(VXvF~uMUPP`K5xCBRC+z|^?J#< zyh3PWX diff --git a/config/__pycache__/wsgi.cpython-311.pyc b/config/__pycache__/wsgi.cpython-311.pyc index a1b4aa75cef92df0f6f3c8f77e2be44ec1b36534..c21cff099e97c93c46cbae7b04722c3720a1239c 100644 GIT binary patch delta 20 acmZ3^x}24JIWI340}w>dzqyfn4if-0bp>1i delta 20 acmZ3^x}24JIWI340}#~ttk}pshY0{Og9Q5k 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 3f553f6185fe97866ef932cffccb11efab5e8ef0..c3387cb24ba5db021002bd5b20912d6259a264c1 100644 GIT binary patch delta 19 ZcmbQsIG2%oIWI340}w>dzd4b65&$f}1rz`P delta 19 ZcmbQsIG2%oIWI340}#~tteD6>2>>fD1hW7D diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 5e8987a0cd478c226e501b5189550e758662c560..61e8d174689eac8edc12e236b43f0755fad149d3 100644 GIT binary patch literal 3930 zcmbVPNpIUm80Co6YDwOtBz6!xd!iPx+s0^-qFWl+K^8l1+Mof^f}qHe&4i0GB<=X7 zLk>OqmYj;7QUv)kHlTw$rvN?WP{4+B%BkNs6lGav5W}SSNY3&&@69)h{w|dY5*+oH z|5*R!Br(Xz*q*x z!9z2S_AzD>7>9sy_|S}%KE`|k;|MU09-6UgR(_Uh)wkIDHNw-n=~(VtjGkJK=Y9-* z%cZ{0N8+b>>eMOom#rqx-3a}_b137vIZDmCZan7lZLiLA0Q1zebe>;ip4YgcGn1F@ z(-n)Z-ltDO>IX(Q+TbCM)!k$8@?4^lN=!*jQCU??qNdFhv{2^dnDv%xL!%gsDrupX z;WUNaFaFp^iWKx@_>C=GeucTc_lipvMUIq!%VLfz% zaGE})Mi_W(devi({ibfvX~Sc5TDUURTw^$Z1mNiQPXM&K=T@=On%ydvTC;evYFu=m zR%|sMyJrFRw1Q6AOo3h*^eSfBB5KCWfbJ|W#nxZPuBoNDf}(iNa!v4`t?^=$S&q)u zG{XwkcqxAK13jQTZ~2;T1l9_zrPw%plbr&n$!rX6T=DI2iJipc2}~v;d7+kNgP1mi zFbu$pw&e$!Y57fCU*iMftzp>#bbiUFIy08FhDB}D=c7$Vn_e@t;p=86kyVgL7=m5Q z&^G}%r}>`%?78@6^g-+H*2t%=J6n~R*8JAVGk;y%7{9Wi9FKkx4KnQWwHrgI@k73X zpJ{$P_H!?kBWD$^gg!=dJ&t;Xc{b$*gHa(y55U7qz|SrM)D%8|h`#;Sn#VJ; z3dLr!ua(99B1v!)`#0Vk_SUX~Z3J69}&YQb8Q zMhbIAXOd%|1D}w?i-zYkZHn=&2Rwu7L%*g(g1?T7v1{-I(!YWmAB|}=t!p7445+c} zT85>&!qbIX?j!Ml(+|&DVgmI0{reKXjDVrzUjXQhjt2U79sOH?Q8jpKqdd{NiDwig zSTEVfj}EAjeKUJJOUaL5CCD1fy6aM#p({M=Q#U}%2AEh2gntky*CA7ssHOM`sHSO2 zZVii^-X2O0qwkRop$6B1FuK(ZquJgt!tzIyUy2vMc#xDMbsCG7I$oepA(|}e8kA?4 z9$4-YA8B~D?X7BIQxvlpHTeik)y=SOTZX1Lo6K9GCcBI}WOfT~><+?ROi1y|`@f-S zAGB>CfaTV5JlE3gWW#PEF9HtI1BR`lVRtcZ?)4gm<&S7sxw|RoIL3YeGG6U=dS{u5 z<{1$=17J-9CgBi#Vz;q$Q8?u<$1x;^-F?Ubw|;^fo5fPYJ>p?M-~}|%>fP?jb z1!Cxk1?ksb3$Xk`?HDhck92p*n=(8%U{;;Ukj^t=v*EKeyfEA>uq*=nNsw3`0k;2zSwiLPX!h@ILoV zv@QCMsZb49O8+_RL$+l?$d+_^WB>1sX(U&?uSAAXz_j8U-21GqcwyEZSv}H%X;UDkyCdrS-!fVS8{A zrA?x=No1*Z%g55zOPge(O(sy;oKTj8FRJaa8Y-J3*rGXtWRrWav7r-85vTTf*CZK zUxE~9GTvfMOv%m6^V4Ly#g~$mn3tZfmzs&fI0#ks&dbZi00hzVZ*JsfWC8#$83c&{ delta 20 acmeBS>0#ks&dbZi00ebDD>ia7G64WBCj=+} diff --git a/core/__pycache__/context_processors.cpython-311.pyc b/core/__pycache__/context_processors.cpython-311.pyc index 75bf2234fb21a6b62efc5cec11af9512dd0c9616..b09f6430b7b63e1fef61e09a5e9d6f7221b64e2a 100644 GIT binary patch literal 1928 zcmZ`(&2JM&6rc6_E3qBOM}Y$CEGZ#1g~YUQK{PZt5x60N;y^`2wQ_bR$(ps-%#48` zNAdxwv}$jyC{hkVqG_rim1B?m33lWPcce&2y}->VR6?Bk#?IQYqQ2dI^Y+be-q*aD z-$J2w1T??$xB1A8&_CkGA=j3$c?^Vy$UufW?YA;SA}vaDXwk*Cq4EggkyZe=Ca^UkkMbzhz@(gf5c!e$91KFt-;`4x zM_v|QcaR~kfW5Vb&E;)&n&9nT4fj*e3V2>?cyG0^=mK;jg5|cRm8QgcnsV#v?`5{P zIrl)Gl??BFf8I5URzSVhn1af*#IPvkr9yk&MF4B~K5XC?CD!~wsgN^a_>Hz!3;%;_ z-+@yc8&K)qrkvX4$gPpK=9iZszQB?PdMbGDM!O0$Vi;7-5<7);cARFhZqAyziZi!O zVrSAg!_?bJVkT!SOx0w@Q0vX5-m%F|noa6h)$I(!cbPh8*)vJYS{SUEo#gHu#f1Bv zE}V+Eh}d1;?PxU3q+7k((x0N=K7()bQ(!+!<4A(jl1JZkiYli-SEuh>H)}ocM<_2{ zLk}eh{m^9~$V{4qGZJWcF^!1KU4v2XHW^OyuAQfY3XiHyZz7IN^6W$Q_c4i7dk zqRZ$BVDDa`SEzuBeJOZXN1vZ8#}dV_&GndBjG4S6W>aKOMCQEv@|8V?5 z(XmAAO8oLv{L&;xywg#~Cq~YXX;-gYjCgpr6O)0dPRuMb?F{d9lx8{!_ePFbyvM*< z%U%#0uQ4-?DNClayi3m!B2?+-jk82X%#w_e`cCf_`(~S znUoQ@7!mi`Gos}v@1WQMK_l3zVZ5`Z#h`P16pAaNl))%JFcO=JUmMfTT}{L%#}WxG z0Sxlh#>cL6Hx!<6mrc1Bu+Y0)HZzR7MFJ5w_Yr(62bqw4!f~LP10@}xp&}kQ311L# zhWIY0C|kQnj)GR)BQyl})Vl(z`qBPy(Y>eA)l(SX@O3=6^NV-cUGnvpef?F`?uo7r zJUv!EdcG!a?AgB&43~pPUIb6A2Twgqm4YMX;K+;Mh4tWt$_EF3x=}g&an*xDap?_` zLmh7gdn0@Hw!cNd9t5gE6z*TrUMSIZCHmKiizQ{EtV|UB`!|$B^{%P&XX7xI`ch@+ z5-VF$Zk3f=fT{$PVxWKZ6JV7?$5y|35-tYA|9X+yx3o}<3_o|3dSm6@*oM-(biFum z`p*$mC4dUGyyP$pWB?fV zrfmaTw6?{hPpC!pen&NAt-kO5X8@D4@Gv-#eNc@Z6`diO~X$xc@D^dE9O@yGxG delta 479 zcmeC+|INy`oR^o20SKzTCVqjPe#1Mc*ojy<|g&9L3D@+Rm zLkT;Aoy9!4hDn7hg=G#i&}FPu;gi=f8LE}!E0koUDwL$=7UU$Bq$(um=ar;ZlqjU< zNO;fbj7`}2%Udg6V|5QPFsml`2h2BfNFDe*ZQ84(x$;!j_ zfdN9WvV8_B1_oXcD7cGwK?KO0l?+9KAT~dc_{Cw9o1apelWJEa4CI2GP;3AsJ}@&f fGTva|YyiU>4BQQ1_<@C!5okV?5I`{5z(xW9!7^WN diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..725b63c173fffce7c7e132c5d27a6d7fcc79b727 GIT binary patch literal 20843 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 T2{~!!yzd2T5VC@(Y;pfDxy?}) literal 209 zcmZ3^%ge<81a&?uGA)7hV-N=hn4pZ$LO{lJh7^Vr#vF!R#wbQch7_h?22JLdAO)I? zx7c#?Q&Mw^{WO_wai(M?=B4NBr6d(G10`27do?nz*T#%TYs-K)+l&TLgMz5gq7l#dyU7C|>SHuC-&IrWCvOwYkGb1D84F;JD K*iaE0Pz3-{*EE9w diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..399ccbbc333e4dff04ffe8d5f108f894e4b56eca GIT binary patch literal 3343 zcmb7GO>7&-6`tkq@=vl&IT59TS^t~0Ew+~10&ZO;b!@9aAq1|Q76jVGiZh~?TJAD4 zOUoiuz=r_!A*UjMe8@os8r6w($RQ|t$T{ed5(tPLz(s?ce3Rkil2hLsYFCtGxAAcJ zIDhYF=DqJd{wx4#3Hb^qL5uGWp1lKu6+#Ks9MaG#nugp`WJ9m$jZ`J2kqEg(X#71w6HHr+60#28eygNu^4FxKKf!59llmPd z{OgurFivuImvOTT|MbJs0SW$re+YqQ1M z?A;a!s`i!iDN4ci%mA`N7^!HKR3cP+k5r;G0yIXWK;tw97R6=$4hJnaJzirs9d8j0 z6V*Tn3ew?=7kTyttga9r*LGVyJhvJN)&_s6MkDxOG)@y6$+ZML34Hr)nV8}8_^4yI z{Xdz@q#p87ou*LwMtTk0w;s5Vfb4MWX9LNEyo_wn6Uv|&r6qev3j|TW`A34$chVB_ z(cH$+8ob?l_>pQ12Qv#Buc>TM`qeN<#OXTgp}>p+POK5 zfJ+!*FqO&Ob(werqQiD&DXx~w?4H$VI&7lqHOe*5t2w}^0$kuitlIKDUS9P0f@oS*R<3%Sm3_qWL~}{T1@nK?Y!{vb^$E3w)`))t zxlbOC8r{+J+oR>J(Q@yof&F-~S2)odEB1+liS6sTg9vY@ISxT zDPG+yPIrsb4`N+?yrcRvxFOk>+z{d_x&0jmE08$qcto|;5I(^Q{`z53AW$Lp!)*WT zkJ>U>4O7*ijAgAJ3+6PkAO0HHusWb4I7i5`MxzJMDlo@pWDLXM$v@$+Oz$}v(3q$? zHgkO$tuddYQkk&?guH*_-InFZ3__$O%&JG39O~~}_gvqyUGZ^*pGF3qGtu;1!35?g z!{BFdbr^%oM75^lSyZ%7?dc8c$i#s|CxKJE4dg!Q08=yk6zpP-vUp9%cnwot`$u=?*^(jVy+z2P zt)}6*hHp2RVY$@s=b3>S&u~0%K^V3RS(4hbP*WhwrrABn#SphuCNmg9cEfXR%i-S# zEi%g^aVf(u;&QC%L8ZYESH{S=?=3KwPvAls2?n1W=4=k)dET7kUc;ob zGSNqXOmGXzT#f$_PVEcyD=LPS&T3GhK$Y#=cbPfI7@f7M3ua)GFjXRNPrnciFDVHF zTYLg!7c$%VQ(O5{kF)vC$mvIKb}vqKFW%@}xY^0x+RWbSW^eVzPj)8jo$Lbir|~GJ zP#_QV9i60dF9g>6!`u3~E&bf4Uh3+lj$YbB;mC)tbqnQB$2awBUHw`|zjkQr=<~oh z1Ib4>Rb@0ynKsRaM_Ueqan>|3_h7v?O@0=x#wU@y4&-B^kgQS+$3Je0AST3{K<tD?214X&tABNnZiVo+)KyhSn{&=BY%*F<*o$@GnI4GiCxzXD~tF z_A9t5-iRtytNVtceS5}#tN*Nj;h*zq*uYXE{sd$vs%ct}7@hsSM_%jf@Bbk;I^V7D WB+hGa)5GN9`KP%0^>enAq5lQMa~q!k literal 0 HcmV?d00001 diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index f705988a223abc7b5af333902925bc196186bbf5..70a5c84589b5d0d44fc0031e33206e98685e3667 100644 GIT binary patch literal 2827 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 XSt5rR^y{HoZq260ty$=vX~q9vSzcvk literal 1364 zcmZ`(&1)M+6ra_{dS%J>I!@|TuH%hUh$T{~CE!96oTjQ1+$K)2Q%ZHHP<`ZWm8k%0`!Liw{@%1fY!tdJefM7AQl zBhn19Sa6Ea_&F0f52+#t(zs7E+?3c%aE4Gt31lN`$cK>CaWDY%0l$fN{S4(m*^98b zAQFOZ8L?*M5b8J{)dItqY^-hhUDsF{YgW-QOsAxII>p%0Y9=dd7Ae9|G&s?)vtv@? z*w|s3fmzYCyi6leblsZSXw)-0vAJ^Fb$=8ZkKM6~%pxUIZ^SlnP1|O53-h^$TMxcl z&pj%9yS|;<+IaYARqQc33p$+=>?7eip%aJUUFM01nxMCzWy{3I+3Mq}!>U=li}flaG+QI|spl4T zob~hDF>%eyxOE;8-gFVJ3n+Sj0BfPw>ijEJJ5jZcy4+QlU#Tl6>PkmVchz)jqo<{g zem>D|w6z=i>HYM}h1U1|$#c)H{hoQ2Y2^ko`uNk{{Bl3J(n+Sf$#g%t+)aMbNnYMq_I%*JNq0Rq;#`{q1K zoZG+JUbuEt>cm&O@zr)@^%Rl>0Md)F(RpB8KAS2P06puedS=OZWkMOW!1G(l6GL;A z0R;Uy__hCq=p}LW6JT$Zn>I137G9%QKox%h?+LI$Sdye3inaW&hh|#-_ZofP9$!6l Xr9HlS=yH2}4P;gNa)91vejfh;`i@)& 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 0000000000000000000000000000000000000000..dd28f9b568055e2817629dd769703d7c2847a095 GIT binary patch literal 8521 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 delta 19 ZcmZ3%xPp;;IWI340}#~tteD6>7XU291k?Zk 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 %} +
+ +
+
+
+ +

Proof > reviews for home service businesses.

+
+
Built for contractors, HVAC, roofing, plumbing, electrical, landscaping, and local service teams.
+
+
+ + - 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" }} -

-
-
- Page updated: {{ current_time|date:"Y-m-d H:i:s" }} (UTC) -
-{% endblock %} \ No newline at end of file + + +
+
+
+
+
+
+

Fast field workflow

+

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

+
+
+
+
+
🛡️
+

Proof-first cards

+

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

+
+
+
+
+
📈
+

Conversion-ready assets

+

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

+
+
+
+
+
+ +
+ +
+ +
+
+
+
+
+
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; +}