diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 5b26592..3fa810b 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc index 399ccbb..3f2750b 100644 Binary files a/core/__pycache__/tests.cpython-311.pyc and b/core/__pycache__/tests.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 70a5c84..8cd37f5 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index be47382..c1ba505 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 2feec86..08274af 100644 --- a/core/models.py +++ b/core/models.py @@ -104,13 +104,19 @@ class Job(models.Model): def __str__(self) -> str: return f'{self.service_type} for {self.customer.full_name}' + def _get_media_by_type(self, media_type: str): + prefetched_media = getattr(self, '_prefetched_objects_cache', {}).get('media') + if prefetched_media is not None: + return next((media for media in prefetched_media if media.media_type == media_type), None) + return self.media.filter(media_type=media_type).first() + @property def before_media(self): - return self.media.filter(media_type=JobMedia.MediaType.BEFORE).first() + return self._get_media_by_type(JobMedia.MediaType.BEFORE) @property def after_media(self): - return self.media.filter(media_type=JobMedia.MediaType.AFTER).first() + return self._get_media_by_type(JobMedia.MediaType.AFTER) def job_media_upload_path(instance: 'JobMedia', filename: str) -> str: diff --git a/core/templates/core/dashboard.html b/core/templates/core/dashboard.html index 698cefe..861561b 100644 --- a/core/templates/core/dashboard.html +++ b/core/templates/core/dashboard.html @@ -13,6 +13,7 @@

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.

+ View public gallery {% if current_membership.can_manage_workspace %}Workspace settings{% endif %} Log a new completed job
diff --git a/core/templates/core/includes/proof_media_grid.html b/core/templates/core/includes/proof_media_grid.html new file mode 100644 index 0000000..43e109a --- /dev/null +++ b/core/templates/core/includes/proof_media_grid.html @@ -0,0 +1,14 @@ +
+
+ {% if job.before_media and job.before_media.file %} + Before photo for {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }} + {% endif %} + Before +
+
+ {% if job.after_media and job.after_media.file %} + After photo for {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }} + {% endif %} + After +
+
diff --git a/core/templates/core/index.html b/core/templates/core/index.html index 962013b..12e0b40 100644 --- a/core/templates/core/index.html +++ b/core/templates/core/index.html @@ -54,9 +54,8 @@
{% if featured_proofs %} {% with proof=featured_proofs.0 %} -
-
Before
-
After
+
+ {% include "core/includes/proof_media_grid.html" with job=proof.job %}
@@ -125,17 +124,18 @@
Featured proof

Recent conversion assets

- Browse proof cards + {% if request.user.is_authenticated and current_membership %} + Open your proof cards + {% else %} + Get started free + {% endif %}
{% for proof in featured_proofs %}
All proof cards + {% if proof_card.status == 'published' %}Open public proof{% endif %} {% if current_membership.can_manage_proof %}Edit proof card{% endif %}
@@ -21,10 +22,7 @@
-
-
Before
-
After
-
+ {% include "core/includes/proof_media_grid.html" with job=proof_card.job grid_class='tf-proof-media-grid-large' %}
{{ proof_card.job.service_type }} @@ -71,6 +69,10 @@

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.status == 'published' %} + + {% endif %} {% if proof_card.job.review_request %} Open review request page {% endif %} diff --git a/core/templates/core/proof_cards_list.html b/core/templates/core/proof_cards_list.html index 305ff70..fa20721 100644 --- a/core/templates/core/proof_cards_list.html +++ b/core/templates/core/proof_cards_list.html @@ -12,7 +12,10 @@

{{ 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 +
@@ -20,10 +23,7 @@
-
-
Before
-
After
-
+ {% include "core/includes/proof_media_grid.html" with job=proof.job %}
{{ proof.job.service_type }} @@ -36,6 +36,9 @@ {{ proof.get_status_display }} {% if proof.rating %}★ {{ proof.rating }}{% else %}No rating yet{% endif %}
+ {% if proof.status == 'published' %} +

Live at /proof/{{ current_membership.business.slug }}/{{ proof.id }}/

+ {% endif %}
diff --git a/core/templates/core/public_proof_detail.html b/core/templates/core/public_proof_detail.html new file mode 100644 index 0000000..3b6fa16 --- /dev/null +++ b/core/templates/core/public_proof_detail.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block title %}{{ proof_card.job.service_type }} in {{ proof_card.job.city }} | {{ business.name }} Proof{% endblock %} +{% block meta_description %}Published proof from {{ business.name }} for {{ proof_card.job.service_type|lower }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}.{% endblock %} + +{% block content %} +
+
+
+
+
Published proof
+

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

+

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

+
+
+ Back to gallery + {% if business.google_review_url %}Read more reviews{% elif request.user.is_authenticated and current_membership and current_membership.business_id == business.id %}Manage this proof card{% endif %} +
+
+ +
+
+
+ {% include "core/includes/proof_media_grid.html" with job=proof_card.job grid_class='tf-proof-media-grid-large' %} +
+
+ {{ proof_card.job.service_type }} + {{ proof_card.verified_label }} +
+
{% if proof_card.testimonial_quote %}“{{ proof_card.testimonial_quote }}”{% else %}This published proof card confirms a completed job by {{ business.name }}.{% endif %}
+
+
Rating{% if proof_card.rating %}★ {{ proof_card.rating }} / 5{% else %}Verified{% endif %}
+
Completed{{ proof_card.job.completed_at|date:"M j, Y" }}
+
Service area{{ proof_card.job.city }}, {{ proof_card.job.state }}
+
+
+
+
+
+
+

Why this matters

+
+
Business{{ business.name }}
+
Customer display{{ proof_card.customer_display_name }}
+
Proof status{{ proof_card.get_status_display }}
+
Placement{{ proof_card.attached_pages|default:'Homepage' }}
+
+
+ +
+

More published proof

+
+ {% for related in related_proofs %} + +
+
{{ related.job.service_type }}
+ {{ related.job.city }}, {{ related.job.state }} +
{{ related.customer_display_name }}
+
+ {{ related.get_status_display }} +
+ {% empty %} +
+

This is the first published proof card

+

Return to the gallery later as {{ business.name }} publishes more completed work.

+
+ {% endfor %} +
+
+
+
+
+
+{% endblock %} diff --git a/core/templates/core/public_proof_gallery.html b/core/templates/core/public_proof_gallery.html new file mode 100644 index 0000000..e5bd1b7 --- /dev/null +++ b/core/templates/core/public_proof_gallery.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} + +{% block title %}{{ business.name }} Proof Gallery | TrustForge{% endblock %} +{% block meta_description %}Browse published proof cards for {{ business.name }} in {{ business.primary_city }}, {{ business.primary_state }}.{% endblock %} + +{% block content %} +
+
+
+
+
Public proof gallery
+

{{ business.name }} completed work

+

Published proof cards from real completed jobs in {{ business.primary_city }}, {{ business.primary_state }}. Use this gallery to validate the quality, consistency, and trust signal behind the brand.

+
+
+ {% if business.google_review_url %}Read Google reviews{% endif %} + {% if request.user.is_authenticated and current_membership and current_membership.business_id == business.id %}Manage proof cards{% else %}Create your own gallery{% endif %} +
+
+ + {% if featured_proofs %} + + {% endif %} + + +
+
+{% endblock %} diff --git a/core/tests.py b/core/tests.py index a15c3e2..a5fa931 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,8 +1,9 @@ from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.urls import reverse -from .models import Business, BusinessMembership, Customer, Job, ProofCard, ReviewRequest +from .models import Business, BusinessMembership, Customer, Job, JobMedia, ProofCard, ReviewRequest User = get_user_model() @@ -41,6 +42,16 @@ class TrustForgeFlowTests(TestCase): city='Austin', state='TX', ) + JobMedia.objects.create( + job=self.job, + media_type=JobMedia.MediaType.BEFORE, + file=SimpleUploadedFile('before-sample.jpg', b'before-image-bytes', content_type='image/jpeg'), + ) + JobMedia.objects.create( + job=self.job, + media_type=JobMedia.MediaType.AFTER, + file=SimpleUploadedFile('after-sample.jpg', b'after-image-bytes', content_type='image/jpeg'), + ) self.proof_card = ProofCard.objects.create(job=self.job, customer_display_name='Verified homeowner') self.review_request = ReviewRequest.objects.create(job=self.job) @@ -123,3 +134,88 @@ class TrustForgeFlowTests(TestCase): self.proof_card.refresh_from_db() self.assertEqual(self.proof_card.status, 'published') self.assertEqual(self.proof_card.rating, 5) + + + def test_public_gallery_only_shows_published_cards_for_requested_business(self): + self.proof_card.status = ProofCard.Status.PUBLISHED + self.proof_card.is_featured = True + self.proof_card.testimonial_quote = 'Published proof for Forge Roofing.' + self.proof_card.save(update_fields=['status', 'is_featured', 'testimonial_quote']) + + other_business = Business.objects.create( + name='Quiet Electric', + slug='quiet-electric', + industry='Electrical', + primary_city='Denver', + primary_state='CO', + ) + other_customer = Customer.objects.create( + business=other_business, + full_name='Morgan Bright', + city='Denver', + state='CO', + ) + other_job = Job.objects.create( + business=other_business, + customer=other_customer, + service_type='Panel upgrade', + city='Denver', + state='CO', + ) + ProofCard.objects.create( + job=other_job, + customer_display_name='Verified homeowner', + status=ProofCard.Status.PUBLISHED, + testimonial_quote='This should not appear in Forge Roofing gallery.', + ) + + draft_customer = Customer.objects.create( + business=self.business, + full_name='Casey Draft', + city='Austin', + state='TX', + ) + draft_job = Job.objects.create( + business=self.business, + customer=draft_customer, + service_type='Draft-only repair', + city='Austin', + state='TX', + ) + ProofCard.objects.create( + job=draft_job, + customer_display_name='Hidden draft', + status=ProofCard.Status.DRAFT, + testimonial_quote='Draft cards should stay private.', + ) + + response = self.client.get(reverse('public_proof_gallery', args=[self.business.slug])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Forge Roofing completed work') + self.assertContains(response, 'Published proof for Forge Roofing.') + self.assertContains(response, self.job.before_media.file.url) + self.assertContains(response, self.job.after_media.file.url) + self.assertNotContains(response, 'This should not appear in Forge Roofing gallery.') + self.assertNotContains(response, 'Draft cards should stay private.') + + + def test_workspace_proof_detail_renders_uploaded_media(self): + self.client.force_login(self.user) + response = self.client.get(reverse('proof_card_detail', args=[self.proof_card.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.job.before_media.file.url) + self.assertContains(response, self.job.after_media.file.url) + + def test_public_proof_detail_requires_published_status(self): + response = self.client.get(reverse('public_proof_detail', args=[self.business.slug, self.proof_card.id])) + self.assertEqual(response.status_code, 404) + + self.proof_card.status = ProofCard.Status.PUBLISHED + self.proof_card.testimonial_quote = 'Proof card is now public.' + self.proof_card.save(update_fields=['status', 'testimonial_quote']) + + response = self.client.get(reverse('public_proof_detail', args=[self.business.slug, self.proof_card.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Proof card is now public.') + self.assertContains(response, self.job.before_media.file.url) + self.assertContains(response, self.job.after_media.file.url) diff --git a/core/urls.py b/core/urls.py index c2c924e..26d2cdc 100644 --- a/core/urls.py +++ b/core/urls.py @@ -17,6 +17,8 @@ from .views import ( proof_card_detail, proof_card_edit, proof_cards_list, + public_proof_detail, + public_proof_gallery, review_request_view, signup, switch_workspace, @@ -43,5 +45,7 @@ urlpatterns = [ 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('proof//', public_proof_gallery, name='public_proof_gallery'), + path('proof///', public_proof_detail, name='public_proof_detail'), path('reviews//', review_request_view, name='review_request'), ] diff --git a/core/views.py b/core/views.py index 7dc086b..86406e8 100644 --- a/core/views.py +++ b/core/views.py @@ -403,7 +403,9 @@ def home(request: HttpRequest) -> HttpResponse: 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 + is_featured=True, + status=ProofCard.Status.PUBLISHED, + job__business__is_active=True, )[:3] recent_jobs = Job.objects.select_related('customer', 'business').prefetch_related('media')[:4] @@ -420,6 +422,47 @@ def home(request: HttpRequest) -> HttpResponse: return render(request, 'core/index.html', context) +@transaction.atomic +def public_proof_gallery(request: HttpRequest, slug: str) -> HttpResponse: + business = get_object_or_404(Business, slug=slug, is_active=True) + proof_cards = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter( + job__business=business, + status=ProofCard.Status.PUBLISHED, + ) + featured_proofs = proof_cards.filter(is_featured=True)[:3] + + context = { + **_theme_context(), + 'business': business, + 'proof_cards': proof_cards, + 'featured_proofs': featured_proofs, + } + return render(request, 'core/public_proof_gallery.html', context) + + +@transaction.atomic +def public_proof_detail(request: HttpRequest, slug: str, card_id: int) -> HttpResponse: + proof_card = get_object_or_404( + ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media'), + id=card_id, + job__business__slug=slug, + job__business__is_active=True, + status=ProofCard.Status.PUBLISHED, + ) + related_proofs = ProofCard.objects.select_related('job__customer', 'job__business').prefetch_related('job__media').filter( + job__business=proof_card.job.business, + status=ProofCard.Status.PUBLISHED, + ).exclude(id=proof_card.id)[:3] + + context = { + **_theme_context(), + 'business': proof_card.job.business, + 'proof_card': proof_card, + 'related_proofs': related_proofs, + } + return render(request, 'core/public_proof_detail.html', context) + + @login_required @business_required @transaction.atomic diff --git a/static/css/custom.css b/static/css/custom.css index 7b80ec6..fa88a12 100644 --- a/static/css/custom.css +++ b/static/css/custom.css @@ -335,6 +335,9 @@ main, .tf-site-header, .tf-footer { } .tf-photo-slot { + position: relative; + isolation: isolate; + overflow: hidden; min-height: 170px; border-radius: 20px; background: @@ -349,10 +352,42 @@ main, .tf-site-header, .tf-footer { box-shadow: inset 0 1px 0 rgba(255,255,255,0.10); } +.tf-photo-slot::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0.56)); + z-index: 1; +} + .tf-photo-slot-after { background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86)); } +.tf-photo-slot-has-media { + background: rgba(15, 23, 42, 0.95); +} + +.tf-photo-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 0; +} + +.tf-photo-label { + position: relative; + z-index: 2; + display: inline-flex; + align-items: center; + padding: 0.5rem 0.8rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.42); + backdrop-filter: blur(8px); +} + .tf-empty-proof { border-radius: 22px; background: rgba(248, 250, 252, 0.72); diff --git a/staticfiles/css/custom.css b/staticfiles/css/custom.css index 7b80ec6..fa88a12 100644 --- a/staticfiles/css/custom.css +++ b/staticfiles/css/custom.css @@ -335,6 +335,9 @@ main, .tf-site-header, .tf-footer { } .tf-photo-slot { + position: relative; + isolation: isolate; + overflow: hidden; min-height: 170px; border-radius: 20px; background: @@ -349,10 +352,42 @@ main, .tf-site-header, .tf-footer { box-shadow: inset 0 1px 0 rgba(255,255,255,0.10); } +.tf-photo-slot::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(15, 23, 42, 0.08), rgba(15, 23, 42, 0.56)); + z-index: 1; +} + .tf-photo-slot-after { background: linear-gradient(135deg, rgba(15, 118, 110, 0.94), rgba(45, 212, 191, 0.86)); } +.tf-photo-slot-has-media { + background: rgba(15, 23, 42, 0.95); +} + +.tf-photo-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + z-index: 0; +} + +.tf-photo-label { + position: relative; + z-index: 2; + display: inline-flex; + align-items: center; + padding: 0.5rem 0.8rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.42); + backdrop-filter: blur(8px); +} + .tf-empty-proof { border-radius: 22px; background: rgba(248, 250, 252, 0.72);