From 5809ee0af7504f29884e4bf89866aa2196511b12 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sat, 11 Apr 2026 02:09:51 +0000 Subject: [PATCH] Auto commit: 2026-04-11T02:09:51.860Z --- core/__pycache__/models.cpython-311.pyc | Bin 17657 -> 18365 bytes core/__pycache__/tests.cpython-311.pyc | Bin 3343 -> 13738 bytes core/__pycache__/urls.cpython-311.pyc | Bin 2827 -> 3070 bytes core/__pycache__/views.cpython-311.pyc | Bin 39244 -> 41537 bytes core/models.py | 10 +- core/templates/core/dashboard.html | 1 + .../core/includes/proof_media_grid.html | 14 +++ core/templates/core/index.html | 20 ++-- core/templates/core/job_detail.html | 14 +-- core/templates/core/proof_card_detail.html | 10 +- core/templates/core/proof_cards_list.html | 13 ++- core/templates/core/public_proof_detail.html | 74 +++++++++++++ core/templates/core/public_proof_gallery.html | 94 +++++++++++++++++ core/tests.py | 98 +++++++++++++++++- core/urls.py | 4 + core/views.py | 45 +++++++- static/css/custom.css | 35 +++++++ staticfiles/css/custom.css | 35 +++++++ 18 files changed, 433 insertions(+), 34 deletions(-) create mode 100644 core/templates/core/includes/proof_media_grid.html create mode 100644 core/templates/core/public_proof_detail.html create mode 100644 core/templates/core/public_proof_gallery.html diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index 5b2659225e8cd7fbbd24a9d25d705ed6bd5e50d8..3fa810b228ea9ba21e7574e0e1127cc6ed0d3bfe 100644 GIT binary patch delta 2094 zcmbtUdrVVT7{BMXx8r@~DOesC1})971q3@c9)&p;HL@v)(@gG4?=6%^i>DU{1O*co zU~`$<$-=T_30Wd)bSi5$|5zp{Swc3GwWNyH>6m{k`o|L7G{Ly(zH^I@P4?GL+TZ!^ z_xzpjeD~fRLEpZQ;tt2gMlrC8j$L*QWlzLy(`K73EuIe9($&=ER=O;5x9sRrJlOJ< z2e)}U?GD-Ele8C;*mqcRFbexlq+LNZ=~Bv|c4v;1A*I6pteh*Q4Km}9_885ruAOa$A>6TjhAK@gaMpQHBg4>H9<$sVzaD>;a)cNsO>bDwR;@)c5j8btXX!;-JQ6? zO%oIWEZ$&lFawM~RfOMI=@b{Vz2>iPoV7LjZH*r%13dioH-??C0ActrfJqm628!7g z1&1^sgtU-cQ5lAGPFK4k;}GX`VXtD+;CvcId52a!(L$AH_#|%%>PbQgPamK+1rFA~ z%*omypLY+y;sA41pEFkCH?5y)3g|Zk^&95&n`iZ#XPN{0hM>N|ACY<~TJMiGUQJvX zKF1Oklz|XP6@$Q&=hDjUg`$}CcseQgCjAg-a{qwqD^pOQ#k&52`U zS)Z6Qfp%#*8(LtV!Wc#ux4CdMI3`@B#&qf;#(=HLiBK#wE8)c!nG_nbAcuY08LFn@(Y$ z?^ja{C33$w%@_0BS(dvF+7HR>>KU{^J~iiZzrph;ahIi%pUjEqHd!#gi2S6$GQ`~j z%ON7HO(N$lEm~Tg{F0wSY70})5h54Xfb;Rf40M@%U${E@CYbRgh2IHV)EYHUMG7DI z_7n{vH1E4#+`w|t@HzW}>?kc1Qb7tuIAvLCvOC)F4sg90LxTbU^>~pSEyY|qIDG>Y z&PLv$rX>z5<7<|}%%bR3uqd7(3D)h&G$sCvI?hw*p>T)nv#yG}24YBu?TX9Yj8Bts zt34``I#W3Ai+_GCVj~fGP@L+suZz%dMo>>fPqwdGq&fT)8Y#q57@%;M{9N`nw+0+9 z5PwDz*;_sgozPXhf-aHT3ZkP_@f4X7lE}RZ6S_k3DmRHLx8*Lhl#;22kWj44E85*2 zcTb0_SC;T;s*6`-uj1$|ww(}@~iY8rnXwOlHX=151pa-O(x;~lCZ}_d9r;^&IJTh6GfnFk4s~1ZQh`t5O U|I>}a8*?>NcV^0G!7=E}+`Y>;{>%1$G z5q>N>Gt3s19K#P|Vh~}#1SC%ah=dX%na1I|beldBqec@UXu2R#;g9!R8Di8Qz8_z| z^L^)@b9(N3=Uykjze*fqMMZXjJ(muAr7NBjj;9KU!x$=}C$ zarnLIf`uZ2&|_KPJ$Kdj37oHJ^d1(~05R=JEv|QWr3d=7fYp?EWLmfBiL^%HMX!62 zDQ(kfe;VHO)~|e?73&W%7!=uRrPhD!a|C7K###bXDQsSc8E8LQCM?= z#00A&FB0hqk{1x?Ele1C`i^A1Jf?!n|5nj^*i@uvu+Le@`2A9b(X?^h zpCn`qTAeP~>n?^jqBa-_bXX5#-cg*#jtig{84SSffR7vkZ|y~ClEp8>wc7832tpSE_B8KP^n|A9S|Uy}&>37#F2k{i+s=23u){bKyq`!&x9(ZI)R+og z5G9jEqp-Jrg8T?w4b{>QEZzgV8|uh)_^@FWnSgx5uyli|@4&7VyGRzsn_W;6c9M4? z5LQ@LU)asm*TSK~Z&?;Sg>Vfng?sE@B1IT6B8|gDLdrW|d89>@EJP5-z}-|&ia~8E zTl6V&J82%0O;lRKyp!;K(*^5KSOc5ZlG8BTyt$>AS@b5Vy!-$%o^*E`rd`|7r%_$& z-Kx=9=9^a9oz{DwqsO5q(rqVL0pXxA6In?lq1pV$J6AJeHn7cm|;e zVGv;+_OE$Gs$+>?K&bU?_U}JyeUwZ?NgG)C#hixmwiYr2SKHdl`KTj!jF0-)#f3m! zTNXOb9CLA6>rd;+RIlEh=z3v$ihZ0oH!Wi={=8rxvQq$S;}1QIv;u7pLM4J5HH43i zrRzQ?B}mx^#^1q@FySoRSnnghLV2t+oI!!V6?}ty9lXHbi{7%WPNw496Izt6f_<^y Z8a)JG_J7CSzR}=3_kOuILxD*p{|A*}Zs7m` diff --git a/core/__pycache__/tests.cpython-311.pyc b/core/__pycache__/tests.cpython-311.pyc index 399ccbbc333e4dff04ffe8d5f108f894e4b56eca..3f2750baca3e9b9513082c4245bedaaf64cdd595 100644 GIT binary patch literal 13738 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 VT#U_s?!zZ4?Cl?)QHYKFe*p{NsE_~v delta 1170 zcma)5OKcNI7@o2BX>US!M3`4(0wHX8mvBI!2mw`T4^dSR5*AXl@r=U;uh;r#4Fy#S z5=d}EFc(f$Pewp)IimL1QxB-8G6E!yxjCXIPW-DbxcIVSS|NAlX{QAS~!%v5X z3Jll#KVHYK`)IhtFF%?bTgnToZY6fbPMnI9xD_|?DqiAOe9m(02PW)$OgNIa^NhW~ z-CLD{a4)jZTcu;jjdw|^>|CTI(k{pw5|mu!B(sfowx!~RRLXegS}IEu2`2wjI&b`+ zAx-rRpW@Eg;Ft*}OB7Zh?^vmxi zmiCeQae^}l&lr#pCJ2H)P*>D>ggb+;lPlRj@(%j~r}Or7hYg_M(Q!TW0+8?7uxOTw zVliqgq=z$nQ$X*)^f@i4t8R_PNm3#4K!fXzs5>1R4A3j@Wl9Nt&ir6xNt zich>M0VLkZLU@yv8D+pMV<^8H$`_x!AI|#BXv8d$B02GcD6>?mH!ij1P0Gnn-mH493Uax#rYGj)q!v|9p30KVD6yH5Re(`i5@=$TaCv@Fc5y*sa;koD zYDr0EUV5>9k{igQZStw4mGevueRj{>HIO=ez+RdQlcN_=JtP?_lD z+iW_FGLt#jXRsH6>;W1%mpzh82gqau;$qiH9D4O)j7%RG298LlkL^aAIA1gR$zG5* zzF-w_fhF)FOW+ljz#A+a4K5crwJ+#8TyT%Mz!H0rCH4wSEL2$Gf|BV48=ng-z86`1 Vudw)j&=u!rYTyRJA}wI}0RRR~v}^zX delta 528 zcmew--YurSoR^o20SJng-OS|SU|@I*;=q6el<`?=qI&+sdFqT3lQ%GmPrNI^ESkbS zIe}4>orj4bl`#t}JXxDj2BKhcJe)Nd$})z^{AM&_6q~HaB*`qEA`LQOay-)|dyy2~ zHSEin85mXrF$AQGL`kOT1v6;szXSA2dbznHso3ut)>we*lHWeJB6` diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index be47382efeb1329ba458616534bdb03d8a0159c1..c1ba505dbbf1ef9379f73edd932fb510d58e7112 100644 GIT binary patch delta 3019 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 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);