Auto commit: 2026-04-11T02:09:51.860Z
This commit is contained in:
parent
159e91248c
commit
5809ee0af7
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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:
|
||||
|
||||
@ -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>
|
||||
|
||||
14
core/templates/core/includes/proof_media_grid.html
Normal file
14
core/templates/core/includes/proof_media_grid.html
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
74
core/templates/core/public_proof_detail.html
Normal file
74
core/templates/core/public_proof_detail.html
Normal 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 %}
|
||||
94
core/templates/core/public_proof_gallery.html
Normal file
94
core/templates/core/public_proof_gallery.html
Normal 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 %}
|
||||
@ -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)
|
||||
|
||||
@ -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'),
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user