From 4c0b29addfcd5c3e0235c1542a35bd90b6d2380f Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sat, 20 Jun 2026 09:04:08 +0330 Subject: [PATCH] Contact reveal modal: click phone/contact on cards and detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/JobsMedical.Web/Pages/Jobs/Details.cshtml | 31 +++------- .../Pages/Shared/_JobCard.cshtml | 2 +- .../Pages/Shared/_Layout.cshtml | 51 +++++++++++++++++ .../Pages/Shared/_RecommendationCard.cshtml | 2 +- .../Pages/Shared/_ShiftCard.cshtml | 2 +- .../Pages/Shared/_TalentCard.cshtml | 2 +- .../Pages/Shifts/Details.cshtml | 31 +++------- .../Pages/Talent/Details.cshtml | 48 +--------------- src/JobsMedical.Web/Program.cs | 56 +++++++++++++++++++ src/JobsMedical.Web/wwwroot/css/site.css | 16 ++++++ 10 files changed, 144 insertions(+), 97 deletions(-) diff --git a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml index d07bf02..4c3e52b 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml @@ -105,10 +105,8 @@
@salary

@empLabel

-
- -
+
@@ -178,26 +176,11 @@ @* Sticky bottom action bar — mobile only. *@
- @if (Model.ShowContact) - { - @if (!string.IsNullOrEmpty(f.Phone)) - { - 📞 تماس با مرکز - } - else - { - اطلاعات تماس در بالای صفحه - } - } - else - { - - - -
- -
- } + +
+ +
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Job?.Facility?.Lat is not null) diff --git a/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml index 304b1e0..b99b5dd 100644 --- a/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml @@ -41,6 +41,6 @@ }
@salary - جزئیات + 📞 تماس
diff --git a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml index e9849b6..9a312a2 100644 --- a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml @@ -275,6 +275,57 @@ })(); + @* 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). *@ + + + @* 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. *@ @if (User.Identity?.IsAuthenticated == true) diff --git a/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml index d070c61..8a9bbe0 100644 --- a/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_RecommendationCard.cshtml @@ -35,6 +35,6 @@
@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent) - جزئیات + 📞 تماس
diff --git a/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml index f32089c..59de956 100644 --- a/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml @@ -40,6 +40,6 @@
@JalaliDate.PayLabel(Model.PayType, Model.PayAmount, Model.SharePercent) - جزئیات + 📞 تماس
diff --git a/src/JobsMedical.Web/Pages/Shared/_TalentCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_TalentCard.cshtml index 2bd4b3d..4abce67 100644 --- a/src/JobsMedical.Web/Pages/Shared/_TalentCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_TalentCard.cshtml @@ -56,6 +56,6 @@ }
@comp - مشاهده و تماس + 📞 مشاهده و تماس
diff --git a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml index 78e657d..dd97244 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml @@ -119,10 +119,8 @@
✓ این فرصت ذخیره شد و در پیشنهادهای شما لحاظ می‌شود.
}
-
- -
+

با اعلام تمایل، اطلاعات تماس مرکز نمایش داده می‌شود.

@@ -196,26 +194,11 @@ @* Sticky bottom action bar — mobile only. Always-reachable primary action (native-app feel). *@
- @if (Model.ShowContact) - { - @if (!string.IsNullOrEmpty(f.Phone)) - { - 📞 تماس با مرکز - } - else - { - اطلاعات تماس در بالای صفحه - } - } - else - { -
- -
-
- -
- } + +
+ +
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Shift?.Facility?.Lat is not null) diff --git a/src/JobsMedical.Web/Pages/Talent/Details.cshtml b/src/JobsMedical.Web/Pages/Talent/Details.cshtml index 1f41a39..535fbfb 100644 --- a/src/JobsMedical.Web/Pages/Talent/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Talent/Details.cshtml @@ -13,16 +13,6 @@ comp = JalaliDate.Toman(pa) + " مدنظر"; else 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"); }
@@ -67,41 +57,9 @@
diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index a3b0dce..1eb5c0d 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -390,6 +390,62 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) => 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(); + + 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) ---- app.MapGet("/search/suggest", async (string? q, AppDbContext db) => { diff --git a/src/JobsMedical.Web/wwwroot/css/site.css b/src/JobsMedical.Web/wwwroot/css/site.css index df2986b..8b311ac 100644 --- a/src/JobsMedical.Web/wwwroot/css/site.css +++ b/src/JobsMedical.Web/wwwroot/css/site.css @@ -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; } .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 ; show it as the primary action. */ +.contact-trigger { cursor: pointer; } + /* ---------- Filters layout ---------- */ .layout-2 { display: grid; grid-template-columns: 270px 1fr; gap: 24px; align-items: start; } .filter-card { position: sticky; top: 84px; }