Contact reveal modal: click phone/contact on cards and detail pages
CI/CD / CI · dotnet build (push) Successful in 2m26s
CI/CD / Deploy · hamkadr (push) Successful in 58s

Adds a lazy-loaded contact modal. Any element with data-contact-type +
data-contact-id (the «📞 تماس» button on shift/job/talent/recommendation cards,
and the contact CTA on the three detail pages) opens a modal that fetches the
listing's numbers from a new GET /contact endpoint and renders them with click-
to-call links. Numbers are loaded only on click, so they never sit in list-page
HTML (privacy / anti-scrape). The endpoint logs the same Apply interest signal
for shift/job that the old inline-reveal POST did, and falls back to the
facility phone (or Divar source link for talent) when an ad has no own contacts.

Verified locally: GET /contact?type=shift&id=1 → {title, contacts:[{value:
'021-82032000', href:'tel:...'}]}, and the modal opens and renders on the shift
detail page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-20 09:04:08 +03:30
parent 0cf5b30dd8
commit 4c0b29addf
10 changed files with 144 additions and 97 deletions
+7 -24
View File
@@ -105,10 +105,8 @@
<div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">@salary</div> <div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">@salary</div>
<p class="muted" style="font-size:13px; margin-top:0;">@empLabel</p> <p class="muted" style="font-size:13px; margin-top:0;">@empLabel</p>
<div class="aside-apply"> <div class="aside-apply">
<form method="post"> <button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
<button type="submit" asp-page-handler="Interest" asp-route-id="@j.Id" data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده راه ارتباطی</button>
</form>
</div> </div>
<div style="display:flex; gap:8px; margin-top:8px;"> <div style="display:flex; gap:8px; margin-top:8px;">
<form method="post" style="flex:1;"> <form method="post" style="flex:1;">
@@ -178,26 +176,11 @@
@* Sticky bottom action bar — mobile only. *@ @* Sticky bottom action bar — mobile only. *@
<div class="mobile-action-bar"> <div class="mobile-action-bar">
@if (Model.ShowContact) <button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
{ data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده تماس</button>
@if (!string.IsNullOrEmpty(f.Phone)) <form method="post">
{ <button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
<a class="btn btn-accent btn-lg cta-main" href="tel:@f.Phone">📞 تماس با مرکز</a> </form>
}
else
{
<span class="cta-main center muted" style="align-self:center;">اطلاعات تماس در بالای صفحه</span>
}
}
else
{
<form method="post" class="cta-main">
<button type="submit" asp-page-handler="Interest" asp-route-id="@j.Id" class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده تماس</button>
</form>
<form method="post">
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
</form>
}
</div> </div>
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Job?.Facility?.Lat is not null) @if (!string.IsNullOrEmpty(Model.MapKey) && Model.Job?.Facility?.Lat is not null)
@@ -41,6 +41,6 @@
} }
<div class="foot"> <div class="foot">
<span class="pay">@salary</span> <span class="pay">@salary</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span> <span class="btn btn-accent contact-trigger" style="padding: 6px 14px;" data-contact-type="job" data-contact-id="@Model.Id">📞 تماس</span>
</div> </div>
</a> </a>
@@ -275,6 +275,57 @@
})(); })();
</script> </script>
@* Contact modal — any element with data-contact-type + data-contact-id opens it; numbers are
fetched from /contact on click (so they never sit in list HTML and bots can't scrape them). *@
<div id="contactModal" class="contact-modal" aria-hidden="true">
<div class="contact-modal-box" role="dialog" aria-modal="true" aria-labelledby="contactModalTitle">
<div class="contact-modal-head">
<h3 id="contactModalTitle">راه‌های ارتباطی</h3>
<button type="button" class="contact-modal-x" data-contact-close aria-label="بستن">✕</button>
</div>
<div id="contactModalBody" class="contact-modal-body"></div>
</div>
</div>
<script>
(function () {
var modal = document.getElementById('contactModal');
var box = document.getElementById('contactModalBody');
var titleEl = document.getElementById('contactModalTitle');
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function open() { modal.classList.add('show'); modal.setAttribute('aria-hidden', 'false'); }
function close() { modal.classList.remove('show'); modal.setAttribute('aria-hidden', 'true'); box.innerHTML = ''; }
function render(d) {
titleEl.textContent = d.title || 'راه‌های ارتباطی';
var html = '';
(d.contacts || []).forEach(function (c) {
html += '<div class="contact-row"><span class="c-meta"><span class="c-type">' + esc(c.icon + ' ' + c.label) +
'</span><span class="c-val" dir="ltr">' + esc(c.value) + '</span></span>' +
(c.href ? '<a class="btn btn-accent" href="' + esc(c.href) + '" target="_blank" rel="nofollow noopener">تماس</a>' : '') + '</div>';
});
if (d.fallbackUrl) html += '<a class="btn btn-accent btn-block" href="' + esc(d.fallbackUrl) +
'" target="_blank" rel="nofollow noopener">' + esc(d.fallbackLabel || 'مشاهده') + '</a>';
box.innerHTML = html || '<p class="muted" style="margin:0;">شماره‌ای ثبت نشده است.</p>';
}
document.addEventListener('click', function (e) {
var trigger = e.target.closest('[data-contact-type]');
if (trigger) {
e.preventDefault(); e.stopPropagation(); // don't follow the card link
titleEl.textContent = 'راه‌های ارتباطی';
box.innerHTML = '<p class="muted" style="margin:0;">در حال دریافت…</p>';
open();
fetch('/contact?type=' + encodeURIComponent(trigger.getAttribute('data-contact-type')) +
'&id=' + encodeURIComponent(trigger.getAttribute('data-contact-id')))
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(render)
.catch(function () { box.innerHTML = '<p class="muted" style="margin:0;">خطا در دریافت اطلاعات تماس.</p>'; });
return;
}
if (e.target.closest('[data-contact-close]') || e.target === modal) close();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
})();
</script>
@* Live in-app notifications over SSE (our own origin — works in Iran, no Google push). @* Live in-app notifications over SSE (our own origin — works in Iran, no Google push).
Updates the bell badge, shows a toast, and fires a local OS notification when allowed. *@ Updates the bell badge, shows a toast, and fires a local OS notification when allowed. *@
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
@@ -35,6 +35,6 @@
<div class="foot"> <div class="foot">
<span class="pay">@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)</span> <span class="pay">@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span> <span class="btn btn-accent contact-trigger" style="padding: 6px 14px;" data-contact-type="shift" data-contact-id="@s.Id">📞 تماس</span>
</div> </div>
</a> </a>
@@ -40,6 +40,6 @@
<partial name="_HourBar" model="Model" /> <partial name="_HourBar" model="Model" />
<div class="foot"> <div class="foot">
<span class="pay">@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent)</span> <span class="pay">@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent)</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span> <span class="btn btn-accent contact-trigger" style="padding: 6px 14px;" data-contact-type="shift" data-contact-id="@Model.Id">📞 تماس</span>
</div> </div>
</a> </a>
@@ -56,6 +56,6 @@
} }
<div class="foot"> <div class="foot">
<span class="pay">@comp</span> <span class="pay">@comp</span>
<span class="btn btn-outline" style="padding: 6px 14px;">مشاهده و تماس</span> <span class="btn btn-accent contact-trigger" style="padding: 6px 14px;" data-contact-type="talent" data-contact-id="@Model.Id">📞 مشاهده و تماس</span>
</div> </div>
</a> </a>
@@ -119,10 +119,8 @@
<div class="alert alert-success" style="margin-bottom:12px;">✓ این فرصت ذخیره شد و در پیشنهادهای شما لحاظ می‌شود.</div> <div class="alert alert-success" style="margin-bottom:12px;">✓ این فرصت ذخیره شد و در پیشنهادهای شما لحاظ می‌شود.</div>
} }
<div class="aside-apply"> <div class="aside-apply">
<form method="post"> <button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
<button type="submit" asp-page-handler="Interest" asp-route-id="@s.Id" data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده راه ارتباطی</button>
</form>
<p class="muted center" style="font-size:12px; margin:8px 0;">با اعلام تمایل، اطلاعات تماس مرکز نمایش داده می‌شود.</p> <p class="muted center" style="font-size:12px; margin:8px 0;">با اعلام تمایل، اطلاعات تماس مرکز نمایش داده می‌شود.</p>
</div> </div>
<div style="display:flex; gap:8px;"> <div style="display:flex; gap:8px;">
@@ -196,26 +194,11 @@
@* Sticky bottom action bar — mobile only. Always-reachable primary action (native-app feel). *@ @* Sticky bottom action bar — mobile only. Always-reachable primary action (native-app feel). *@
<div class="mobile-action-bar"> <div class="mobile-action-bar">
@if (Model.ShowContact) <button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
{ data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده تماس</button>
@if (!string.IsNullOrEmpty(f.Phone)) <form method="post">
{ <button type="submit" asp-page-handler="Save" asp-route-id="@s.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
<a class="btn btn-accent btn-lg cta-main" href="tel:@f.Phone">📞 تماس با مرکز</a> </form>
}
else
{
<span class="cta-main center muted" style="align-self:center;">اطلاعات تماس در بالای صفحه</span>
}
}
else
{
<form method="post" class="cta-main">
<button type="submit" asp-page-handler="Interest" asp-route-id="@s.Id" class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده تماس</button>
</form>
<form method="post">
<button type="submit" asp-page-handler="Save" asp-route-id="@s.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
</form>
}
</div> </div>
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Shift?.Facility?.Lat is not null) @if (!string.IsNullOrEmpty(Model.MapKey) && Model.Shift?.Facility?.Lat is not null)
@@ -13,16 +13,6 @@
comp = JalaliDate.Toman(pa) + " مدنظر"; comp = JalaliDate.Toman(pa) + " مدنظر";
else else
comp = "توافقی"; comp = "توافقی";
string? telHref = null;
if (!string.IsNullOrWhiteSpace(t.Phone))
{
var digits = new string(t.Phone.Where(char.IsDigit).ToArray());
if (digits.Length >= 7) telHref = "tel:" + digits;
}
// Only Divar is surfaced as a fallback source (and only when no number was extracted).
// We never name other crawl sources (medjobs/telegram/…) publicly.
bool isDivar = !string.IsNullOrWhiteSpace(t.SourceUrl)
&& System.Uri.TryCreate(t.SourceUrl, UriKind.Absolute, out var su) && su.Host.Contains("divar");
} }
<div class="page-head"> <div class="page-head">
@@ -67,41 +57,9 @@
<aside> <aside>
<div class="card card-pad"> <div class="card card-pad">
<h3 style="margin-top:0;">راه‌های ارتباطی</h3> <h3 style="margin-top:0;">راه‌های ارتباطی</h3>
@{ var contacts = (t.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).OrderBy(c => c.SortOrder).ToList(); } <button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
@if (contacts.Count > 0) data-contact-type="talent" data-contact-id="@t.Id">📞 مشاهده راه‌های ارتباطی</button>
{ <p class="muted" style="font-size:12px; margin:10px 0 0;">با کلیک، شماره تماس و راه‌های ارتباطی نمایش داده می‌شود.</p>
<div class="contact-reveal">
@foreach (var c in contacts)
{
var href = JobsMedical.Web.Services.ContactInfo.Href(c.Type, c.Value);
var label = JobsMedical.Web.Services.ContactInfo.Label(c.Type);
var icon = JobsMedical.Web.Services.ContactInfo.Icon(c.Type);
var cls = c.Type is JobsMedical.Web.Models.ContactType.Mobile or JobsMedical.Web.Models.ContactType.Phone ? "btn-accent" : "btn-outline";
<div class="contact-row">
<span class="c-meta"><span class="c-type">@icon @label</span><span class="c-val" dir="ltr">@c.Value</span></span>
@if (href is not null)
{
<a class="btn @cls" href="@href" target="_blank" rel="nofollow noopener">باز کردن</a>
}
</div>
}
</div>
}
else if (telHref is not null)
{
<a href="@telHref" class="btn btn-accent btn-block btn-lg" dir="ltr">📞 @t.Phone</a>
}
else if (isDivar)
{
@* Divar hides the number behind a login-gated reveal — point to the original ad. *@
<p class="muted" style="margin-top:0;">مشاهده شماره در وبسایت دیوار</p>
<a href="@t.SourceUrl" target="_blank" rel="nofollow noopener" class="btn btn-accent btn-block btn-lg">مشاهده شماره در دیوار ↗</a>
<p class="muted" style="font-size:12px; margin:10px 0 0;">برای دریافت شماره به آگهی اصلی در دیوار مراجعه کن.</p>
}
else
{
<p class="muted">شماره تماس ثبت نشده است.</p>
}
</div> </div>
</aside> </aside>
</div> </div>
+56
View File
@@ -390,6 +390,62 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) =>
return Results.Content(sb.ToString(), "application/xml"); return Results.Content(sb.ToString(), "application/xml");
}); });
// ---- Contact reveal (modal): a listing's contact channels as JSON, fetched lazily on click so
// personal numbers never sit in list-page HTML. Logs the Apply interest signal for shift/job. ----
app.MapGet("/contact", async (string? type, int id, AppDbContext db, InterestService interest) =>
{
object Item(ContactType ct, string value) => new
{
icon = ContactInfo.Icon(ct), label = ContactInfo.Label(ct), value, href = ContactInfo.Href(ct, value),
};
string? title = null, fallbackUrl = null, fallbackLabel = null;
var items = new List<object>();
switch ((type ?? "").ToLowerInvariant())
{
case "shift":
{
var s = await db.Shifts.Include(x => x.Role).Include(x => x.Facility).Include(x => x.Contacts)
.FirstOrDefaultAsync(x => x.Id == id);
if (s is null) return Results.NotFound();
title = s.Role?.Name ?? "تماس";
items.AddRange(s.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
if (items.Count == 0 && !string.IsNullOrWhiteSpace(s.Facility?.Phone)) items.Add(Item(ContactType.Phone, s.Facility!.Phone!));
if (!string.IsNullOrWhiteSpace(s.Facility?.BaleId)) items.Add(Item(ContactType.Bale, s.Facility!.BaleId!));
await interest.LogAsync(InterestEventType.Apply, id);
break;
}
case "job":
{
var j = await db.JobOpenings.Include(x => x.Role).Include(x => x.Facility).Include(x => x.Contacts)
.FirstOrDefaultAsync(x => x.Id == id);
if (j is null) return Results.NotFound();
title = j.Title;
items.AddRange(j.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
if (items.Count == 0 && !string.IsNullOrWhiteSpace(j.Facility?.Phone)) items.Add(Item(ContactType.Phone, j.Facility!.Phone!));
if (!string.IsNullOrWhiteSpace(j.Facility?.BaleId)) items.Add(Item(ContactType.Bale, j.Facility!.BaleId!));
await interest.LogJobAsync(InterestEventType.Apply, id);
break;
}
case "talent":
{
var t = await db.TalentListings.Include(x => x.Role).Include(x => x.Contacts)
.FirstOrDefaultAsync(x => x.Id == id);
if (t is null) return Results.NotFound();
title = string.IsNullOrWhiteSpace(t.PersonName) ? (t.Role?.Name ?? "آماده به کار") : t.PersonName;
items.AddRange(t.Contacts.OrderBy(c => c.SortOrder).Select(c => Item(c.Type, c.Value)));
if (items.Count == 0 && !string.IsNullOrWhiteSpace(t.Phone)) items.Add(Item(ContactType.Mobile, t.Phone!));
if (items.Count == 0 && !string.IsNullOrWhiteSpace(t.SourceUrl)
&& Uri.TryCreate(t.SourceUrl, UriKind.Absolute, out var su) && su.Host.Contains("divar"))
{ fallbackUrl = t.SourceUrl; fallbackLabel = "مشاهده شماره در دیوار ↗"; }
break;
}
default: return Results.BadRequest();
}
return Results.Json(new { title, contacts = items, fallbackUrl, fallbackLabel });
});
// ---- Instant search suggestions (typeahead dropdown) ---- // ---- Instant search suggestions (typeahead dropdown) ----
app.MapGet("/search/suggest", async (string? q, AppDbContext db) => app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
{ {
+16
View File
@@ -363,6 +363,22 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
.contact-row .btn { flex: 0 0 auto; padding: 6px 14px; } .contact-row .btn { flex: 0 0 auto; padding: 6px 14px; }
.badge-gender { background: #f3eefb; color: #6b3fa0; } .badge-gender { background: #f3eefb; color: #6b3fa0; }
/* ---------- Contact modal (lazy-loaded numbers) ---------- */
.contact-modal { position: fixed; inset: 0; z-index: 200; display: none; align-items: center;
justify-content: center; padding: 16px; background: rgba(15,23,42,.55); }
.contact-modal.show { display: flex; animation: revealIn .2s ease; }
.contact-modal-box { background: var(--surface); border-radius: 16px; width: 100%; max-width: 420px;
box-shadow: 0 24px 60px rgba(0,0,0,.3); overflow: hidden; animation: revealIn .25s cubic-bezier(.2,.7,.3,1); }
.contact-modal-head { display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--line); }
.contact-modal-head h3 { margin: 0; font-size: 16px; }
.contact-modal-x { background: none; border: none; font-size: 18px; cursor: pointer; color: var(--muted);
line-height: 1; padding: 4px 6px; border-radius: 8px; }
.contact-modal-x:hover { background: var(--bg); color: var(--ink); }
.contact-modal-body { padding: 14px 16px; }
/* The card-level trigger sits inside an <a>; show it as the primary action. */
.contact-trigger { cursor: pointer; }
/* ---------- Filters layout ---------- */ /* ---------- Filters layout ---------- */
.layout-2 { display: grid; grid-template-columns: 270px 1fr; gap: 24px; align-items: start; } .layout-2 { display: grid; grid-template-columns: 270px 1fr; gap: 24px; align-items: start; }
.filter-card { position: sticky; top: 84px; } .filter-card { position: sticky; top: 84px; }