Auto commit: 2026-04-11T02:09:51.860Z

This commit is contained in:
Flatlogic Bot 2026-04-11 02:09:51 +00:00
parent 159e91248c
commit 5809ee0af7
18 changed files with 433 additions and 34 deletions

View File

@ -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:

View File

@ -13,6 +13,7 @@
<p class="tf-page-subtitle">This dashboard is scoped to your current workspace only. Track completed jobs, response volume, published proof, and the conversion signal your team is creating this week.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' current_membership.business.slug %}" target="_blank" rel="noopener">View public gallery</a>
{% if current_membership.can_manage_workspace %}<a class="btn tf-btn tf-btn-secondary" href="{% url 'workspace_settings' %}">Workspace settings</a>{% endif %}
<a class="btn tf-btn tf-btn-primary" href="{% url 'job_create' %}">Log a new completed job</a>
</div>

View File

@ -0,0 +1,14 @@
<div class="tf-proof-media-grid{% if grid_class %} {{ grid_class }}{% endif %}">
<div class="tf-photo-slot{% if job.before_media and job.before_media.file %} tf-photo-slot-has-media{% endif %}">
{% if job.before_media and job.before_media.file %}
<img src="{{ job.before_media.file.url }}" alt="Before photo for {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}" class="tf-photo-image" width="960" height="720">
{% endif %}
<span class="tf-photo-label">Before</span>
</div>
<div class="tf-photo-slot tf-photo-slot-after{% if job.after_media and job.after_media.file %} tf-photo-slot-has-media{% endif %}">
{% if job.after_media and job.after_media.file %}
<img src="{{ job.after_media.file.url }}" alt="After photo for {{ job.service_type|lower }} in {{ job.city }}, {{ job.state }}" class="tf-photo-image" width="960" height="720">
{% endif %}
<span class="tf-photo-label">After</span>
</div>
</div>

View File

@ -54,9 +54,8 @@
<div class="tf-proof-preview mt-4">
{% if featured_proofs %}
{% with proof=featured_proofs.0 %}
<div class="tf-proof-media-grid mb-3">
<div class="tf-photo-slot">Before</div>
<div class="tf-photo-slot tf-photo-slot-after">After</div>
<div class="mb-3">
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
</div>
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
@ -125,17 +124,18 @@
<div class="tf-eyebrow">Featured proof</div>
<h2 class="tf-section-title">Recent conversion assets</h2>
</div>
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_cards_list' %}">Browse proof cards</a>
{% if request.user.is_authenticated and current_membership %}
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_cards_list' %}">Open your proof cards</a>
{% else %}
<a class="btn tf-btn tf-btn-secondary" href="{% url 'signup' %}">Get started free</a>
{% endif %}
</div>
<div class="row g-4">
{% for proof in featured_proofs %}
<div class="col-lg-4">
<a class="tf-proof-card-link" href="{% url 'proof_card_detail' proof.id %}">
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' proof.job.business.slug proof.id %}">
<article class="tf-proof-card h-100">
<div class="tf-proof-media-grid">
<div class="tf-photo-slot">Before</div>
<div class="tf-photo-slot tf-photo-slot-after">After</div>
</div>
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
@ -179,7 +179,7 @@
<div class="tf-activity-row">
<div>
<strong>{{ job.service_type }}</strong>
<div class="small text-secondary-emphasis">{{ job.customer.full_name }} · {{ job.city }}, {{ job.state }}</div>
<div class="small text-secondary-emphasis">{{ job.business.name }} · {{ job.city }}, {{ job.state }}</div>
</div>
<span class="tf-status-pill tf-status-{{ job.status }}">{{ job.get_status_display }}</span>
</div>

View File

@ -35,17 +35,9 @@
<h3 class="h6 text-uppercase text-secondary-emphasis mb-2">Description</h3>
<p class="mb-0">{{ job.description|default:'No extra description was added for this completed job.' }}</p>
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="tf-proof-media-grid tf-proof-media-grid-static">
<div class="tf-photo-slot">{% if job.before_media %}<a class="tf-inline-link" href="{{ job.before_media.file.url }}">Before photo</a>{% else %}Before photo{% endif %}</div>
</div>
</div>
<div class="col-md-6">
<div class="tf-proof-media-grid tf-proof-media-grid-static">
<div class="tf-photo-slot tf-photo-slot-after">{% if job.after_media %}<a class="tf-inline-link" href="{{ job.after_media.file.url }}">After photo</a>{% else %}After photo{% endif %}</div>
</div>
</div>
<div>
<h3 class="h6 text-uppercase text-secondary-emphasis mb-2">Job photos</h3>
{% include "core/includes/proof_media_grid.html" with job=job %}
</div>
</div>
</div>

View File

@ -14,6 +14,7 @@
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn tf-btn tf-btn-secondary" href="{% url 'proof_cards_list' %}">All proof cards</a>
{% if proof_card.status == 'published' %}<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_detail' proof_card.job.business.slug proof_card.id %}" target="_blank" rel="noopener">Open public proof</a>{% endif %}
{% if current_membership.can_manage_proof %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_edit' proof_card.id %}">Edit proof card</a>{% endif %}
</div>
</div>
@ -21,10 +22,7 @@
<div class="row g-4">
<div class="col-lg-7">
<article class="tf-proof-card tf-proof-card-expanded h-100">
<div class="tf-proof-media-grid tf-proof-media-grid-large">
<div class="tf-photo-slot">Before</div>
<div class="tf-photo-slot tf-photo-slot-after">After</div>
</div>
{% include "core/includes/proof_media_grid.html" with job=proof_card.job grid_class='tf-proof-media-grid-large' %}
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
<span class="tf-card-tag">{{ proof_card.job.service_type }}</span>
@ -71,6 +69,10 @@
<h2 class="h4 mb-3">Placement</h2>
<div class="tf-detail-box mb-3"><span>Attach to widgets</span><strong>{{ proof_card.attached_widget_label|default:'Homepage proof gallery' }}</strong></div>
<div class="tf-detail-box mb-3"><span>Attach to pages</span><strong>{{ proof_card.attached_pages|default:'Homepage' }}</strong></div>
<div class="tf-detail-box mb-3"><span>Public gallery</span><strong><a class="tf-inline-link" href="{% url 'public_proof_gallery' proof_card.job.business.slug %}" target="_blank" rel="noopener">/proof/{{ proof_card.job.business.slug }}/</a></strong></div>
{% if proof_card.status == 'published' %}
<div class="tf-detail-box mb-3"><span>Public proof page</span><strong><a class="tf-inline-link" href="{% url 'public_proof_detail' proof_card.job.business.slug proof_card.id %}" target="_blank" rel="noopener">/proof/{{ proof_card.job.business.slug }}/{{ proof_card.id }}/</a></strong></div>
{% endif %}
{% if proof_card.job.review_request %}
<a class="btn tf-btn tf-btn-secondary w-100" href="{% url 'review_request' proof_card.job.review_request.token %}">Open review request page</a>
{% endif %}

View File

@ -12,7 +12,10 @@
<h1 class="tf-page-title">{{ current_membership.business.name }} proof gallery</h1>
<p class="tf-page-subtitle">These conversion assets are tenant-scoped to the active workspace and ready for review, publishing, or featuring.</p>
</div>
<a class="btn tf-btn tf-btn-secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
<div class="d-flex flex-wrap gap-2">
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' current_membership.business.slug %}" target="_blank" rel="noopener">Open public gallery</a>
<a class="btn tf-btn tf-btn-secondary" href="{% url 'dashboard' %}">Back to dashboard</a>
</div>
</div>
<div class="row g-4">
@ -20,10 +23,7 @@
<div class="col-lg-4 col-md-6">
<a class="tf-proof-card-link" href="{% url 'proof_card_detail' proof.id %}">
<article class="tf-proof-card h-100">
<div class="tf-proof-media-grid">
<div class="tf-photo-slot">Before</div>
<div class="tf-photo-slot tf-photo-slot-after">After</div>
</div>
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
@ -36,6 +36,9 @@
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}No rating yet{% endif %}</span>
</div>
{% if proof.status == 'published' %}
<p class="small text-secondary-emphasis mt-3 mb-0">Live at <span class="tf-inline-link">/proof/{{ current_membership.business.slug }}/{{ proof.id }}/</span></p>
{% endif %}
</div>
</article>
</a>

View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}{{ proof_card.job.service_type }} in {{ proof_card.job.city }} | {{ business.name }} Proof{% endblock %}
{% block meta_description %}Published proof from {{ business.name }} for {{ proof_card.job.service_type|lower }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Published proof</div>
<h1 class="tf-page-title">{{ proof_card.job.service_type }} in {{ proof_card.job.city }}, {{ proof_card.job.state }}</h1>
<p class="tf-page-subtitle">Completed by {{ business.name }} · {{ proof_card.customer_display_name }} · {{ proof_card.job.completed_at|date:"F j, Y" }}</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a class="btn tf-btn tf-btn-secondary" href="{% url 'public_proof_gallery' business.slug %}">Back to gallery</a>
{% if business.google_review_url %}<a class="btn tf-btn tf-btn-primary" href="{{ business.google_review_url }}" target="_blank" rel="noopener">Read more reviews</a>{% elif request.user.is_authenticated and current_membership and current_membership.business_id == business.id %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_card_detail' proof_card.id %}">Manage this proof card</a>{% endif %}
</div>
</div>
<div class="row g-4">
<div class="col-lg-7">
<article class="tf-proof-card tf-proof-card-expanded h-100">
{% include "core/includes/proof_media_grid.html" with job=proof_card.job grid_class='tf-proof-media-grid-large' %}
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3 flex-wrap">
<span class="tf-card-tag">{{ proof_card.job.service_type }}</span>
<span class="tf-badge-verified">{{ proof_card.verified_label }}</span>
</div>
<blockquote class="tf-testimonial mb-4">{% if proof_card.testimonial_quote %}“{{ proof_card.testimonial_quote }}”{% else %}This published proof card confirms a completed job by {{ business.name }}.{% endif %}</blockquote>
<div class="row g-3">
<div class="col-md-4"><div class="tf-detail-box"><span>Rating</span><strong>{% if proof_card.rating %}★ {{ proof_card.rating }} / 5{% else %}Verified{% endif %}</strong></div></div>
<div class="col-md-4"><div class="tf-detail-box"><span>Completed</span><strong>{{ proof_card.job.completed_at|date:"M j, Y" }}</strong></div></div>
<div class="col-md-4"><div class="tf-detail-box"><span>Service area</span><strong>{{ proof_card.job.city }}, {{ proof_card.job.state }}</strong></div></div>
</div>
</div>
</article>
</div>
<div class="col-lg-5">
<div class="tf-panel mb-4">
<h2 class="h4 mb-3">Why this matters</h2>
<div class="d-grid gap-3">
<div class="tf-detail-box"><span>Business</span><strong>{{ business.name }}</strong></div>
<div class="tf-detail-box"><span>Customer display</span><strong>{{ proof_card.customer_display_name }}</strong></div>
<div class="tf-detail-box"><span>Proof status</span><strong>{{ proof_card.get_status_display }}</strong></div>
<div class="tf-detail-box"><span>Placement</span><strong>{{ proof_card.attached_pages|default:'Homepage' }}</strong></div>
</div>
</div>
<div class="tf-panel">
<h2 class="h4 mb-3">More published proof</h2>
<div class="d-grid gap-3">
{% for related in related_proofs %}
<a href="{% url 'public_proof_detail' business.slug related.id %}" class="tf-proof-mini">
<div>
<div class="tf-card-tag mb-2">{{ related.job.service_type }}</div>
<strong>{{ related.job.city }}, {{ related.job.state }}</strong>
<div class="small text-secondary-emphasis">{{ related.customer_display_name }}</div>
</div>
<span class="tf-status-pill tf-status-{{ related.status }}">{{ related.get_status_display }}</span>
</a>
{% empty %}
<div class="tf-empty-state text-start">
<h3 class="h5 mb-2">This is the first published proof card</h3>
<p class="text-secondary-emphasis mb-0">Return to the gallery later as {{ business.name }} publishes more completed work.</p>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</section>
{% endblock %}

View File

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}{{ business.name }} Proof Gallery | TrustForge{% endblock %}
{% block meta_description %}Browse published proof cards for {{ business.name }} in {{ business.primary_city }}, {{ business.primary_state }}.{% endblock %}
{% block content %}
<section class="py-5">
<div class="container">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<div class="tf-eyebrow">Public proof gallery</div>
<h1 class="tf-page-title">{{ business.name }} completed work</h1>
<p class="tf-page-subtitle">Published proof cards from real completed jobs in {{ business.primary_city }}, {{ business.primary_state }}. Use this gallery to validate the quality, consistency, and trust signal behind the brand.</p>
</div>
<div class="d-flex flex-wrap gap-2">
{% if business.google_review_url %}<a class="btn tf-btn tf-btn-secondary" href="{{ business.google_review_url }}" target="_blank" rel="noopener">Read Google reviews</a>{% endif %}
{% if request.user.is_authenticated and current_membership and current_membership.business_id == business.id %}<a class="btn tf-btn tf-btn-primary" href="{% url 'proof_cards_list' %}">Manage proof cards</a>{% else %}<a class="btn tf-btn tf-btn-primary" href="{% url 'signup' %}">Create your own gallery</a>{% endif %}
</div>
</div>
{% if featured_proofs %}
<div class="tf-panel mb-4">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3 mb-3">
<div>
<div class="tf-eyebrow">Featured proof</div>
<h2 class="h3 mb-0">Standout published jobs</h2>
</div>
<div class="small text-secondary-emphasis">{{ proof_cards|length }} published proof card{{ proof_cards|length|pluralize }}</div>
</div>
<div class="row g-4">
{% for proof in featured_proofs %}
<div class="col-lg-4">
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' business.slug proof.id %}">
<article class="tf-proof-card h-100">
{% include "core/includes/proof_media_grid.html" with job=proof.job %}
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
</div>
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:110 }}”{% else %}Published proof from a real completed job by {{ business.name }}.{% endif %}</p>
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}Verified{% endif %}</span>
</div>
</div>
</article>
</a>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="row g-4">
{% for proof in proof_cards %}
<div class="col-lg-4 col-md-6">
<a class="tf-proof-card-link" href="{% url 'public_proof_detail' business.slug proof.id %}">
<article class="tf-proof-card h-100">
<div class="tf-proof-media-grid">
<div class="tf-photo-slot">Before</div>
<div class="tf-photo-slot tf-photo-slot-after">After</div>
</div>
<div class="tf-proof-card-body">
<div class="d-flex justify-content-between align-items-center mb-3 gap-3">
<span class="tf-card-tag">{{ proof.job.service_type }}</span>
<span class="tf-badge-verified">{{ proof.verified_label }}</span>
</div>
<h2 class="h5 mb-1">{{ proof.job.city }}, {{ proof.job.state }}</h2>
<p class="small text-secondary-emphasis mb-3">{{ proof.customer_display_name }} · {{ proof.job.completed_at|date:"M j, Y" }}</p>
<p class="mb-3">{% if proof.testimonial_quote %}“{{ proof.testimonial_quote|truncatechars:120 }}”{% else %}Published proof asset ready for customers comparing local providers.{% endif %}</p>
<div class="d-flex justify-content-between align-items-center small text-secondary-emphasis">
<span class="tf-status-pill tf-status-{{ proof.status }}">{{ proof.get_status_display }}</span>
<span>{% if proof.rating %}★ {{ proof.rating }}{% else %}Verified{% endif %}</span>
</div>
</div>
</article>
</a>
</div>
{% empty %}
<div class="col-12">
<div class="tf-empty-state text-center">
<h2 class="h4 mb-2">No public proof cards yet</h2>
<p class="text-secondary-emphasis mb-4">{{ business.name }} has not published any proof cards yet. Check back soon for verified completed work.</p>
<a class="btn tf-btn tf-btn-primary" href="{% url 'signup' %}">Build a proof gallery like this</a>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
{% endblock %}

View File

@ -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)

View File

@ -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/<int:card_id>/', proof_card_detail, name='proof_card_detail'),
path('proof-cards/<int:card_id>/edit/', proof_card_edit, name='proof_card_edit'),
path('proof/<slug:slug>/', public_proof_gallery, name='public_proof_gallery'),
path('proof/<slug:slug>/<int:card_id>/', public_proof_detail, name='public_proof_detail'),
path('reviews/<uuid:token>/', review_request_view, name='review_request'),
]

View File

@ -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

View File

@ -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);

View File

@ -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);