Files
hamkadr/src/JobsMedical.Web/Pages/Shifts/Details.cshtml
T
soroush.asadi c1c914df9f
CI/CD / CI · dotnet build (push) Successful in 2m54s
CI/CD / Deploy · hamkadr (push) Successful in 2m48s
Add per-user Like (پسندیدن) with a liked page and counts
Logged-in users can like a listing (job/shift/talent); dislike is removed per request — only likes.
- Like model (polymorphic by TargetType+TargetId) + EF migration; unique per (user, listing).
- POST /like toggles the like (auth required) and returns {liked, count}.
- Detail pages: the old ♡ Save / ✕ Dismiss buttons are replaced by a single heart Like button that
  shows the live count and toggles in place; clicking while logged out redirects to login.
- New «❤️ پسندیده‌ها» page (/Me/Liked) lists everything the user liked (open listings only), with a
  nav entry shown only when authenticated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:25:10 +03:30

230 lines
14 KiB
Plaintext

@page "{id:int}"
@model JobsMedical.Web.Pages.Shifts.DetailsModel
@{
var s = Model.Shift!;
var f = s.Facility!;
var hasFac = JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f); // false for the «نامشخص» placeholder
var shiftContacts = (s.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).ToList();
// Map: prefer the listing's own approx coords (aggregated ads) then the facility's. Aggregated =
// approximate → shown as an area circle with a disclaimer, never a precise pin.
var mapLat = s.Lat ?? f.Lat;
var mapLng = s.Lng ?? f.Lng;
var mapApprox = s.Source == JobsMedical.Web.Models.ShiftSource.Aggregated;
ViewData["Title"] = hasFac ? $"شیفت {s.SpecialtyRequired} - {f.Name}" : $"شیفت {s.SpecialtyRequired} — {f.City?.Name}";
ViewData["Description"] = hasFac
? $"شیفت {s.SpecialtyRequired} در {f.Name}، {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}."
: $"شیفت {s.SpecialtyRequired} در {f.City?.Name}، تاریخ {JalaliDate.ToLongDate(s.Date)} از ساعت {JalaliDate.Time(s.StartTime)}.";
// Past/filled shifts shouldn't stay in the index as dead pages.
if (s.Status != JobsMedical.Web.Models.ShiftStatus.Open || s.Date < DateOnly.FromDateTime(DateTime.UtcNow))
ViewData["NoIndex"] = true;
var (badgeClass, typeLabel) = s.ShiftType switch
{
ShiftType.Day => ("badge-day", "شیفت صبح"),
ShiftType.Evening => ("badge-evening", "شیفت عصر"),
ShiftType.Night => ("badge-night", "شیفت شب"),
_ => ("badge-oncall", "آنکال"),
};
var crumbs = new List<JobsMedical.Web.Services.Crumb> { new("خانه", "/"), new("شیفت‌ها", "/Shifts") };
if (s.Role is not null) crumbs.Add(new(s.Role.Name, "/شیفت/" + JobsMedical.Web.Services.SeoSlug.Of(s.Role.Name)));
crumbs.Add(new(JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) ? f.Name : "جزئیات شیفت", null));
}
<div class="page-head">
<div class="container">
<partial name="_Breadcrumbs" model="crumbs" />
<div class="row" style="display:flex; gap:10px; align-items:center;">
<span class="badge @badgeClass">@typeLabel</span>
@if (f.IsVerified)
{
<span class="badge badge-verified">✓ مرکز تأیید شده</span>
}
</div>
<h1 style="margin-top:8px;">@s.SpecialtyRequired@(hasFac ? " — " + f.Name : "")</h1>
<p class="muted">📍 @f.City?.Name @(string.IsNullOrEmpty(f.Address) ? "" : "، " + f.Address)</p>
</div>
</div>
<div class="container section has-action-bar">
<div class="detail-grid">
<div>
@if (Model.ShowContact)
{
<div class="contact-reveal" style="margin-bottom:16px;">
<h4>✓ راه‌های ارتباطی</h4>
@if (shiftContacts.Count > 0)
{
@* Numbers from THIS ad (aggregated) — the correct, per-listing contacts. *@
<partial name="_ContactList" model="shiftContacts" />
}
else if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) && (!string.IsNullOrEmpty(f.Phone) || !string.IsNullOrEmpty(f.BaleId)))
{
@if (!string.IsNullOrEmpty(f.Phone))
{
<div class="contact-row">
<span class="c-meta"><span class="c-type">📞 تلفن مرکز</span><span class="c-val" dir="ltr">@f.Phone</span></span>
<a class="btn btn-accent" href="tel:@f.Phone">تماس</a>
</div>
}
@if (!string.IsNullOrEmpty(f.BaleId))
{
<div class="contact-row">
<span class="c-meta"><span class="c-type">💬 بله</span><span class="c-val" dir="ltr">@f.BaleId</span></span>
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
</div>
}
}
else
{
<p class="muted" style="margin:0;">شماره‌ای ثبت نشده است.</p>
}
</div>
}
<div class="card card-pad">
<h3 style="margin-top:0;">جزئیات شیفت</h3>
<div class="info-row"><span class="k">تاریخ</span><span class="v">@JalaliDate.WeekDayName(s.Date)، @JalaliDate.ToLongDate(s.Date)</span></div>
<div class="info-row"><span class="k">ساعت</span><span class="v">@JalaliDate.Time(s.StartTime) تا @JalaliDate.Time(s.EndTime)</span></div>
<div class="info-row"><span class="k">مدت</span><span class="v">@JalaliDate.ToPersianDigits(s.DurationHours.ToString("0.#")) ساعت</span></div>
<div class="info-row"><span class="k">نقش مورد نیاز</span><span class="v">@(s.Role?.Name ?? s.SpecialtyRequired)</span></div>
@if (s.GenderRequirement != Gender.Any)
{
<div class="info-row"><span class="k">جنسیت</span><span class="v">@JalaliDate.GenderLabel(s.GenderRequirement)</span></div>
}
<div class="info-row"><span class="k">پرداخت</span><span class="v" style="color:var(--primary-dark)">@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)</span></div>
<div style="padding-top:12px;">
<span class="k" style="font-size:13px; color:var(--muted);">بازه ساعت کاری در شبانه‌روز</span>
<partial name="_HourBar" model="s" />
</div>
</div>
@if (!string.IsNullOrEmpty(s.Description))
{
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">توضیحات</h3>
<p class="muted" style="margin:0;">@s.Description</p>
</div>
}
@if (hasFac && Model.MoreAtFacility.Count > 0)
{
<h3 style="margin:26px 0 14px;">شیفت‌های دیگر این مرکز</h3>
<div class="grid grid-3">
@foreach (var more in Model.MoreAtFacility)
{
<partial name="_ShiftCard" model="more" />
}
</div>
}
</div>
<aside>
<div class="card card-pad">
<div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">
@JalaliDate.PayLabel(s.PayType, s.PayAmount, s.SharePercent)
</div>
@if (s.PayAmount is not null && s.SharePercent is not null)
{
<p class="muted" style="font-size:13px; margin-top:0;">می‌توانی هنگام هماهنگی، یکی از دو حالت را با مرکز انتخاب کنی.</p>
}
@if (Model.Saved)
{
<div class="alert alert-success" style="margin-bottom:12px;">✓ این فرصت ذخیره شد و در پیشنهادهای شما لحاظ می‌شود.</div>
}
<div class="aside-apply">
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
<p class="muted center" style="font-size:12px; margin:8px 0;">با اعلام تمایل، اطلاعات تماس مرکز نمایش داده می‌شود.</p>
</div>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger"
data-like-type="shift" data-like-id="@s.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
@if (Model.Reported)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
}
else
{
<details style="margin-top:10px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">گزارش تخلف یا اطلاعات نادرست</summary>
<form method="post" action="/report" style="margin-top:8px;">
<input type="hidden" name="targetType" value="Shift" />
<input type="hidden" name="targetId" value="@s.Id" />
<input type="hidden" name="label" value="@(s.Role?.Name) — @s.Facility?.Name" />
<input type="hidden" name="returnUrl" value="/Shifts/Details/@s.Id" />
<textarea name="reason" rows="2" placeholder="دلیل گزارش..." required></textarea>
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ارسال گزارش</button>
</form>
</details>
<details style="margin-top:6px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این @(hasFac ? "مرکز (" + f.Name + ")" : "آگهی")</summary>
<form method="post" action="/report" style="margin-top:8px;">
<input type="hidden" name="targetType" value="Facility" />
<input type="hidden" name="targetId" value="@f.Id" />
<input type="hidden" name="label" value="@f.Name" />
<input type="hidden" name="returnUrl" value="/Shifts/Details/@s.Id" />
<textarea name="reason" rows="2" placeholder="شکایت یا گزارش درباره این مرکز..." required></textarea>
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ثبت شکایت</button>
</form>
</details>
}
</div>
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">موقعیت مکانی</h3>
@if (mapLat is not null && mapLng is not null)
{
var latS = mapLat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var lngS = mapLng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
@if (!string.IsNullOrEmpty(Model.MapKey))
{
<div id="facmap" data-lat="@latS" data-lng="@lngS" data-approx="@(mapApprox ? "true" : "false")" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
}
else
{
<div style="background:var(--primary-soft); border-radius:10px; height:140px; display:grid; place-items:center; color:var(--primary-dark); text-align:center; padding:10px;">
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
</div>
}
@if (mapApprox)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">📍 محدودهٔ تقریبی (برگرفته از آگهی منبع)؛ موقعیت دقیق نیست.</p>
}
<a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener"
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
}
else
{
<p class="muted" style="margin:0;">مختصات این آگهی ثبت نشده است.</p>
}
</div>
</aside>
</div>
</div>
@* Sticky bottom action bar — mobile only. Always-reachable primary action (native-app feel). *@
<div class="mobile-action-bar">
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده تماس</button>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-lg like-trigger" aria-label="پسندیدن"
data-like-type="shift" data-like-id="@s.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> <span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
</div>
@if (!string.IsNullOrEmpty(Model.MapKey) && mapLat is not null)
{
<partial name="_NeshanMap" model="Model.MapKey" />
}
@section Head {
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(crumbs, bu) + "</script>")
@* Only for a real named employer — Google for Jobs rejects a placeholder hiringOrganization. *@
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
{
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ShiftPosting(s, bu) + "</script>")
}
}