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>
This commit is contained in:
@@ -34,6 +34,7 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
||||
public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
|
||||
public DbSet<IngestionRun> IngestionRuns => Set<IngestionRun>();
|
||||
public DbSet<Review> Reviews => Set<Review>();
|
||||
public DbSet<Like> Likes => Set<Like>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder b)
|
||||
{
|
||||
@@ -167,6 +168,12 @@ public class AppDbContext : DbContext, IDataProtectionKeyContext
|
||||
.HasOne(c => c.JobOpening).WithMany(j => j.Contacts)
|
||||
.HasForeignKey(c => c.JobOpeningId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// One like per user per listing; fast count by target.
|
||||
b.Entity<Like>().HasIndex(l => new { l.UserId, l.TargetType, l.TargetId }).IsUnique();
|
||||
b.Entity<Like>().HasIndex(l => new { l.TargetType, l.TargetId });
|
||||
b.Entity<Like>().HasOne(l => l.User).WithMany()
|
||||
.HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique();
|
||||
|
||||
b.Entity<Notification>()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Likes : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Likes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||
TargetType = table.Column<int>(type: "integer", nullable: false),
|
||||
TargetId = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Likes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Likes_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Likes_TargetType_TargetId",
|
||||
table: "Likes",
|
||||
columns: new[] { "TargetType", "TargetId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Likes_UserId_TargetType_TargetId",
|
||||
table: "Likes",
|
||||
columns: new[] { "UserId", "TargetType", "TargetId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Likes");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -727,6 +727,36 @@ namespace JobsMedical.Web.Migrations
|
||||
b.ToTable("JobOpenings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Like", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("TargetId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TargetType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TargetType", "TargetId");
|
||||
|
||||
b.HasIndex("UserId", "TargetType", "TargetId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Likes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -1467,6 +1497,17 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Navigation("Role");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Like", b =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.User", "User")
|
||||
|
||||
@@ -119,6 +119,9 @@ public enum ContactType
|
||||
Other = 8 // سایر
|
||||
}
|
||||
|
||||
/// <summary>What a <see cref="Like"/> points at.</summary>
|
||||
public enum LikeTargetType { Shift = 0, Job = 1, Talent = 2 }
|
||||
|
||||
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
|
||||
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace JobsMedical.Web.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A logged-in user's «پسندیدن» of a listing (shift / job / talent). One row per (user, listing);
|
||||
/// toggling removes it. Polymorphic by <see cref="TargetType"/> + <see cref="TargetId"/> so one table
|
||||
/// covers all three listing kinds. The count of rows for a target is the public "likes" number.
|
||||
/// </summary>
|
||||
public class Like
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int UserId { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
|
||||
public LikeTargetType TargetType { get; set; }
|
||||
public int TargetId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -119,14 +119,11 @@
|
||||
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
|
||||
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; margin-top:8px;">
|
||||
<form method="post" style="flex:1;">
|
||||
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-block">♡ ذخیره</button>
|
||||
</form>
|
||||
<form method="post" style="flex:1;">
|
||||
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@j.Id" class="btn btn-outline btn-block">✕ علاقهمند نیستم</button>
|
||||
</form>
|
||||
</div>
|
||||
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger" style="margin-top:8px;"
|
||||
data-like-type="job" data-like-id="@j.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>
|
||||
@@ -197,9 +194,10 @@
|
||||
<div class="mobile-action-bar">
|
||||
<button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
|
||||
data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده تماس</button>
|
||||
<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>
|
||||
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-lg like-trigger" aria-label="پسندیدن"
|
||||
data-like-type="job" data-like-id="@j.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)
|
||||
|
||||
@@ -23,6 +23,8 @@ public class DetailsModel : PageModel
|
||||
|
||||
public JobOpening? Job { get; private set; }
|
||||
public string? MapKey { get; private set; }
|
||||
public int LikeCount { get; private set; }
|
||||
public bool IsLiked { get; private set; }
|
||||
public bool ShowContact { get; private set; }
|
||||
public bool Saved { get; private set; }
|
||||
public bool Reported { get; private set; }
|
||||
@@ -35,6 +37,9 @@ public class DetailsModel : PageModel
|
||||
// signal for permanent removal, so search engines deindex it cleanly (we keep the row for audit).
|
||||
if (Job.Status == ShiftStatus.Archived) return StatusCode(StatusCodes.Status410Gone);
|
||||
MapKey = (await _settings.GetAsync()).NeshanMapKey;
|
||||
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Job && l.TargetId == id);
|
||||
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
|
||||
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Job && l.TargetId == id);
|
||||
Reported = Request.Query["reported"] == "1";
|
||||
await _interest.LogJobAsync(InterestEventType.View, id);
|
||||
return Page();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Me.LikedModel
|
||||
@{
|
||||
ViewData["Title"] = "پسندیدهها";
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>❤️ پسندیدهها</h1>
|
||||
<p class="muted">فرصتهایی که پسندیدهای — برای حذف، دوباره روی دکمهٔ ♥ بزن.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section">
|
||||
@if (Model.Total == 0)
|
||||
{
|
||||
<div class="card empty-state">
|
||||
هنوز چیزی نپسندیدهای. در <a asp-page="/Jobs/Index">استخدام</a>،
|
||||
<a asp-page="/Shifts/Index">شیفتها</a> و <a asp-page="/Talent/Index">آمادهبهکار</a>
|
||||
فرصتها را ببین و آنهایی که دوست داری را با دکمهٔ ♥ پسند کن.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Jobs.Count > 0)
|
||||
{
|
||||
<div class="section-head"><h2>موقعیتهای استخدامی (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))</h2></div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var j in Model.Jobs) { <partial name="_JobCard" model="j" /> }
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Shifts.Count > 0)
|
||||
{
|
||||
<div class="section-head" style="margin-top:18px;"><h2>شیفتها (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))</h2></div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var s in Model.Shifts) { <partial name="_ShiftCard" model="s" /> }
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (Model.Talent.Count > 0)
|
||||
{
|
||||
<div class="section-head" style="margin-top:18px;"><h2>آماده به کار (@JalaliDate.ToPersianDigits(Model.Talent.Count.ToString()))</h2></div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var t in Model.Talent) { <partial name="_TalentCard" model="t" /> }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Pages.Me;
|
||||
|
||||
/// <summary>«پسندیدهها» — the listings the logged-in user has liked (jobs, shifts, talent), newest
|
||||
/// first. Only still-open listings are shown; un-liking is done with the same heart button.</summary>
|
||||
[Authorize]
|
||||
public class LikedModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public LikedModel(AppDbContext db) => _db = db;
|
||||
|
||||
public List<JobOpening> Jobs { get; private set; } = new();
|
||||
public List<Shift> Shifts { get; private set; } = new();
|
||||
public List<TalentListing> Talent { get; private set; } = new();
|
||||
public int Total => Jobs.Count + Shifts.Count + Talent.Count;
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var uid = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value);
|
||||
var likes = await _db.Likes.Where(l => l.UserId == uid)
|
||||
.OrderByDescending(l => l.CreatedAt).ToListAsync();
|
||||
|
||||
var jobIds = likes.Where(l => l.TargetType == LikeTargetType.Job).Select(l => l.TargetId).ToList();
|
||||
var shiftIds = likes.Where(l => l.TargetType == LikeTargetType.Shift).Select(l => l.TargetId).ToList();
|
||||
var talentIds = likes.Where(l => l.TargetType == LikeTargetType.Talent).Select(l => l.TargetId).ToList();
|
||||
|
||||
Jobs = await _db.JobOpenings
|
||||
.Include(j => j.Facility).ThenInclude(f => f.City)
|
||||
.Include(j => j.Facility).ThenInclude(f => f.District)
|
||||
.Include(j => j.Role)
|
||||
.Where(j => jobIds.Contains(j.Id) && j.Status == ShiftStatus.Open).ToListAsync();
|
||||
Shifts = await _db.Shifts
|
||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||
.Include(s => s.Role)
|
||||
.Where(s => shiftIds.Contains(s.Id) && s.Status == ShiftStatus.Open).ToListAsync();
|
||||
Talent = await _db.TalentListings
|
||||
.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
|
||||
.Where(t => talentIds.Contains(t.Id) && t.Status == ShiftStatus.Open).ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,10 @@
|
||||
<nav class="main-nav">
|
||||
<a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
|
||||
<a asp-page="/Recommendations/Index" class="@(path.StartsWith("/Recommendations") ? "active" : null)">✨ پیشنهادها</a>
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<a asp-page="/Me/Liked" class="@(path.StartsWith("/Me/Liked") ? "active" : null)">❤️ پسندیدهها</a>
|
||||
}
|
||||
<a href="/Shifts" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفتها</a>
|
||||
<a href="/Jobs" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
|
||||
<a asp-page="/Talent/Index" class="@(path.StartsWith("/Talent") ? "active" : null)">آماده به کار</a>
|
||||
@@ -290,6 +294,36 @@
|
||||
<div id="contactModalBody" class="contact-modal-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
@* Like («پسندیدن») toggle — login-gated, updates the button state + count in place. *@
|
||||
<script>
|
||||
(function () {
|
||||
function fa(n) { return String(n).replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[+d]; }); }
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.like-trigger');
|
||||
if (!b) return;
|
||||
e.preventDefault();
|
||||
if (document.body.dataset.authed !== '1') {
|
||||
location.href = '/Account/Login?returnUrl=' + encodeURIComponent(location.pathname);
|
||||
return;
|
||||
}
|
||||
var fd = new FormData();
|
||||
fd.append('type', b.dataset.likeType);
|
||||
fd.append('id', b.dataset.likeId);
|
||||
b.disabled = true;
|
||||
fetch('/like', { method: 'POST', body: fd })
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
||||
.then(function (d) {
|
||||
b.dataset.liked = d.liked ? 'true' : 'false';
|
||||
b.classList.toggle('btn-accent', d.liked);
|
||||
b.classList.toggle('btn-outline', !d.liked);
|
||||
var ico = b.querySelector('.like-ico'); if (ico) ico.textContent = d.liked ? '♥' : '♡';
|
||||
var c = b.querySelector('.like-count'); if (c) c.textContent = fa(d.count);
|
||||
})
|
||||
.catch(function () {})
|
||||
.finally(function () { b.disabled = false; });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
var modal = document.getElementById('contactModal');
|
||||
|
||||
@@ -135,16 +135,11 @@
|
||||
data-contact-type="shift" data-contact-id="@s.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
|
||||
<p class="muted center" style="font-size:12px; margin:8px 0;">با اعلام تمایل، اطلاعات تماس مرکز نمایش داده میشود.</p>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<form method="post" style="flex:1;">
|
||||
<button type="submit" asp-page-handler="Save" asp-route-id="@s.Id"
|
||||
class="btn btn-outline btn-block">♡ ذخیره</button>
|
||||
</form>
|
||||
<form method="post" style="flex:1;">
|
||||
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@s.Id"
|
||||
class="btn btn-outline btn-block">✕ علاقهمند نیستم</button>
|
||||
</form>
|
||||
</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>
|
||||
@@ -212,9 +207,10 @@
|
||||
<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>
|
||||
<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>
|
||||
<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)
|
||||
|
||||
@@ -24,6 +24,8 @@ public class DetailsModel : PageModel
|
||||
public Shift? Shift { get; private set; }
|
||||
public List<Shift> MoreAtFacility { get; private set; } = new();
|
||||
public string? MapKey { get; private set; }
|
||||
public int LikeCount { get; private set; }
|
||||
public bool IsLiked { get; private set; }
|
||||
|
||||
// Set after the visitor taps "interested" — reveals the facility contact (handoff model).
|
||||
public bool ShowContact { get; private set; }
|
||||
@@ -38,6 +40,9 @@ public class DetailsModel : PageModel
|
||||
// signal for permanent removal, so search engines deindex it cleanly (we keep the row for audit).
|
||||
if (Shift.Status == ShiftStatus.Archived) return StatusCode(StatusCodes.Status410Gone);
|
||||
MapKey = (await _settings.GetAsync()).NeshanMapKey;
|
||||
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Shift && l.TargetId == id);
|
||||
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
|
||||
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Shift && l.TargetId == id);
|
||||
Reported = Request.Query["reported"] == "1";
|
||||
await _interest.LogAsync(InterestEventType.View, id); // behavioral signal for recommendations
|
||||
return Page();
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
<button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
|
||||
data-contact-type="talent" data-contact-id="@t.Id">📞 مشاهده راههای ارتباطی</button>
|
||||
<p class="muted" style="font-size:12px; margin:10px 0 0;">با کلیک، شماره تماس و راههای ارتباطی نمایش داده میشود.</p>
|
||||
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger" style="margin-top:10px;"
|
||||
data-like-type="talent" data-like-id="@t.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 (t.Lat is not null && t.Lng is not null)
|
||||
|
||||
@@ -19,6 +19,8 @@ public class DetailsModel : PageModel
|
||||
|
||||
public TalentListing? Item { get; private set; }
|
||||
public string? MapKey { get; private set; }
|
||||
public int LikeCount { get; private set; }
|
||||
public bool IsLiked { get; private set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(int id)
|
||||
{
|
||||
@@ -30,6 +32,9 @@ public class DetailsModel : PageModel
|
||||
.FirstOrDefaultAsync(t => t.Id == id);
|
||||
if (Item is null) return NotFound();
|
||||
MapKey = (await _settings.GetAsync()).NeshanMapKey;
|
||||
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Talent && l.TargetId == id);
|
||||
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
|
||||
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Talent && l.TargetId == id);
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,24 @@ app.MapPost("/report", async (HttpContext ctx, AppDbContext db, VisitorContext v
|
||||
return Results.Redirect((string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl) + "?reported=1");
|
||||
}).DisableAntiforgery();
|
||||
|
||||
// Toggle a logged-in user's «پسندیدن» of a listing; returns the new state + total like count.
|
||||
app.MapPost("/like", async (HttpContext ctx, AppDbContext db, [FromForm] string type, [FromForm] int id) =>
|
||||
{
|
||||
int? uid = ctx.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier) is { } c
|
||||
&& int.TryParse(c.Value, out var n) ? n : null;
|
||||
if (uid is null) return Results.Unauthorized();
|
||||
if (!Enum.TryParse<LikeTargetType>(type, true, out var tt)) return Results.BadRequest();
|
||||
|
||||
var existing = await db.Likes.FirstOrDefaultAsync(l => l.UserId == uid && l.TargetType == tt && l.TargetId == id);
|
||||
bool liked;
|
||||
if (existing is null) { db.Likes.Add(new Like { UserId = uid.Value, TargetType = tt, TargetId = id }); liked = true; }
|
||||
else { db.Likes.Remove(existing); liked = false; }
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var count = await db.Likes.CountAsync(l => l.TargetType == tt && l.TargetId == id);
|
||||
return Results.Json(new { liked, count });
|
||||
}).RequireAuthorization().DisableAntiforgery();
|
||||
|
||||
app.MapGet("/sw.js", () => Results.Content("""
|
||||
const CACHE = 'hamkadr-v1';
|
||||
self.addEventListener('install', e => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/']))); });
|
||||
|
||||
Reference in New Issue
Block a user