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
+56
View File
@@ -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<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) ----
app.MapGet("/search/suggest", async (string? q, AppDbContext db) =>
{