-
+ {% 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" }}
+
+
+
+
+
+
+
+ {% 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
+
+
+
+
+
+
+{% 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 featured_proofs %}
+
+
+
+
Featured proof
+
Standout published jobs
+
+
{{ proof_cards|length }} published proof card{{ proof_cards|length|pluralize }}
+
+
+ {% for proof in featured_proofs %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+ {% for proof in proof_cards %}
+
+ {% empty %}
+
+
+
No public proof cards yet
+
{{ business.name }} has not published any proof cards yet. Check back soon for verified completed work.
+
Build a proof gallery like this
+
+
+ {% endfor %}
+
+
+
+{% 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);