Initial commit — AsadiTools v1.0
Full ASP.NET Core 10 Razor Pages app for آساد ابزار tool repair shop in Karaj, Iran (official DeWalt representative). Features: - Homepage, Services, DeWalt page, Shop (pagination + images) - 10 brand SEO pages (/brands/*) with rich Persian content + FAQ schema - Blog engine with admin management (/blog, /Admin/Blog) - Cart, Checkout, Contact (OpenStreetMap embed) - Admin panel: Products CRUD, Orders, Blog, Change Password - Jalali date formatting, product images, SiteData centralised contact - Docker + docker-compose with healthcheck - Gitea CI/CD via .gitea/workflows/ci-cd.yml (NuGet through Nexus mirror) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.Blog.AdminBlogCreateModel
|
||||
@{ ViewData["Title"] = "نوشته جدید"; Layout = "_AdminLayout"; }
|
||||
|
||||
<div class="p-6 md:p-8 max-w-3xl">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<a href="/Admin/Blog" class="text-gray-400 hover:text-gray-600 text-sm">← بازگشت</a>
|
||||
<h1 class="text-2xl font-extrabold text-gray-900">نوشته جدید</h1>
|
||||
</div>
|
||||
<form method="post" class="bg-white rounded-2xl border border-gray-100 p-6">
|
||||
@Html.AntiForgeryToken()
|
||||
@await Html.PartialAsync("_BlogFormFields", Model.Input)
|
||||
<div class="flex gap-3 mt-6 pt-5 border-t">
|
||||
<button type="submit" class="flex-1 bg-blue-700 text-white py-3 rounded-xl font-bold hover:bg-blue-800 transition-colors">
|
||||
ذخیره مقاله
|
||||
</button>
|
||||
<a href="/Admin/Blog" class="px-5 border border-gray-200 rounded-xl text-sm text-gray-600 hover:bg-gray-50 transition-colors flex items-center">
|
||||
انصراف
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,58 @@
|
||||
using AsadiTools.Data;
|
||||
using AsadiTools.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace AsadiTools.Pages.Admin.Blog;
|
||||
|
||||
[Authorize(AuthenticationSchemes = "AdminCookie")]
|
||||
public class AdminBlogCreateModel(AppDbContext db) : PageModel
|
||||
{
|
||||
[BindProperty] public BlogPostInput Input { get; set; } = new();
|
||||
|
||||
public void OnGet() { ViewData["Title"] = "نوشته جدید"; }
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
if (!ModelState.IsValid) return Page();
|
||||
|
||||
var slug = string.IsNullOrWhiteSpace(Input.Slug)
|
||||
? Input.Title.ToLower()
|
||||
.Replace(" ", "-")
|
||||
.Replace("،", "")
|
||||
.Replace(".", "-")
|
||||
: Input.Slug.Trim();
|
||||
|
||||
var now = DateTime.Now;
|
||||
db.BlogPosts.Add(new BlogPost
|
||||
{
|
||||
Title = Input.Title,
|
||||
Slug = slug,
|
||||
Content = Input.Content,
|
||||
Excerpt = Input.Excerpt,
|
||||
MetaDescription = Input.MetaDescription,
|
||||
FeaturedImage = string.IsNullOrWhiteSpace(Input.FeaturedImage) ? null : Input.FeaturedImage,
|
||||
Tags = Input.Tags,
|
||||
IsPublished = Input.IsPublished,
|
||||
PublishedAt = Input.IsPublished ? now : null,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return RedirectToPage("/Admin/Blog/Index");
|
||||
}
|
||||
}
|
||||
|
||||
public class BlogPostInput
|
||||
{
|
||||
[Required] public string Title { get; set; } = string.Empty;
|
||||
public string? Slug { get; set; }
|
||||
[Required] public string Content { get; set; } = string.Empty;
|
||||
public string? Excerpt { get; set; }
|
||||
public string? MetaDescription { get; set; }
|
||||
public string? FeaturedImage { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
public bool IsPublished { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.Blog.AdminBlogEditModel
|
||||
@{ Layout = "_AdminLayout"; }
|
||||
|
||||
<div class="p-6 md:p-8 max-w-3xl">
|
||||
<div class="flex items-center gap-3 mb-8">
|
||||
<a href="/Admin/Blog" class="text-gray-400 hover:text-gray-600 text-sm">← بازگشت</a>
|
||||
<h1 class="text-2xl font-extrabold text-gray-900">ویرایش مقاله</h1>
|
||||
</div>
|
||||
<form method="post" class="bg-white rounded-2xl border border-gray-100 p-6">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" value="@Model.PostId" />
|
||||
@await Html.PartialAsync("_BlogFormFields", Model.Input)
|
||||
<div class="flex gap-3 mt-6 pt-5 border-t">
|
||||
<button type="submit" class="flex-1 bg-blue-700 text-white py-3 rounded-xl font-bold hover:bg-blue-800 transition-colors">
|
||||
ذخیره تغییرات
|
||||
</button>
|
||||
<a href="/Admin/Blog" class="px-5 border border-gray-200 rounded-xl text-sm text-gray-600 hover:bg-gray-50 transition-colors flex items-center">
|
||||
انصراف
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
using AsadiTools.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace AsadiTools.Pages.Admin.Blog;
|
||||
|
||||
[Authorize(AuthenticationSchemes = "AdminCookie")]
|
||||
public class AdminBlogEditModel(AppDbContext db) : PageModel
|
||||
{
|
||||
[BindProperty] public BlogPostInput Input { get; set; } = new();
|
||||
public int PostId { get; private set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(int id)
|
||||
{
|
||||
var post = await db.BlogPosts.FindAsync(id);
|
||||
if (post is null) return NotFound();
|
||||
PostId = id;
|
||||
Input = new BlogPostInput
|
||||
{
|
||||
Title = post.Title,
|
||||
Slug = post.Slug,
|
||||
Content = post.Content,
|
||||
Excerpt = post.Excerpt,
|
||||
MetaDescription = post.MetaDescription,
|
||||
FeaturedImage = post.FeaturedImage,
|
||||
Tags = post.Tags,
|
||||
IsPublished = post.IsPublished,
|
||||
};
|
||||
ViewData["Title"] = "ویرایش: " + post.Title;
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync(int id)
|
||||
{
|
||||
if (!ModelState.IsValid) { PostId = id; return Page(); }
|
||||
|
||||
var post = await db.BlogPosts.FindAsync(id);
|
||||
if (post is null) return NotFound();
|
||||
|
||||
var wasUnpublished = !post.IsPublished;
|
||||
post.Title = Input.Title;
|
||||
post.Slug = string.IsNullOrWhiteSpace(Input.Slug) ? post.Slug : Input.Slug.Trim();
|
||||
post.Content = Input.Content;
|
||||
post.Excerpt = Input.Excerpt;
|
||||
post.MetaDescription = Input.MetaDescription;
|
||||
post.FeaturedImage = string.IsNullOrWhiteSpace(Input.FeaturedImage) ? null : Input.FeaturedImage;
|
||||
post.Tags = Input.Tags;
|
||||
post.IsPublished = Input.IsPublished;
|
||||
post.UpdatedAt = DateTime.Now;
|
||||
if (Input.IsPublished && wasUnpublished)
|
||||
post.PublishedAt = DateTime.Now;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return RedirectToPage("/Admin/Blog/Index");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.Blog.AdminBlogIndexModel
|
||||
@{ ViewData["Title"] = "مدیریت بلاگ"; Layout = "_AdminLayout"; }
|
||||
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-extrabold text-gray-900">مدیریت بلاگ</h1>
|
||||
<a href="/Admin/Blog/Create"
|
||||
class="bg-blue-700 text-white px-5 py-2.5 rounded-xl font-bold hover:bg-blue-800 transition-colors flex items-center gap-2">
|
||||
✏️ نوشته جدید
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (!Model.Posts.Any())
|
||||
{
|
||||
<div class="text-center py-20 text-gray-400">
|
||||
<div class="text-5xl mb-4">📝</div>
|
||||
<p>هنوز مقالهای ثبت نشده</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="p-4 text-right font-medium text-gray-500">عنوان</th>
|
||||
<th class="p-4 text-right font-medium text-gray-500 hidden md:table-cell">برچسبها</th>
|
||||
<th class="p-4 text-right font-medium text-gray-500">وضعیت</th>
|
||||
<th class="p-4 text-right font-medium text-gray-500 hidden lg:table-cell">تاریخ</th>
|
||||
<th class="p-4 text-right font-medium text-gray-500">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@foreach (var post in Model.Posts)
|
||||
{
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="p-4">
|
||||
<div class="font-medium text-gray-900 line-clamp-1">@post.Title</div>
|
||||
<div class="text-xs text-gray-400 font-mono mt-0.5">@post.EffectiveSlug</div>
|
||||
</td>
|
||||
<td class="p-4 hidden md:table-cell">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@foreach (var tag in post.TagList.Take(3))
|
||||
{
|
||||
<span class="text-xs bg-blue-50 text-blue-600 px-1.5 py-0.5 rounded">@tag</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<form method="post" asp-page-handler="TogglePublish" class="inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" value="@post.Id" />
|
||||
<button type="submit"
|
||||
class="text-xs px-2.5 py-1 rounded-full font-medium border transition-colors @(post.IsPublished ? "bg-green-100 text-green-700 border-green-200 hover:bg-green-200" : "bg-gray-100 text-gray-500 border-gray-200 hover:bg-gray-200")">
|
||||
@(post.IsPublished ? "✅ منتشر" : "⏸ پیشنویس")
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="p-4 hidden lg:table-cell text-gray-400 text-xs">
|
||||
@SiteData.ToJalali(post.CreatedAt)
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/Admin/Blog/Edit?id=@post.Id"
|
||||
class="text-blue-600 hover:underline text-xs font-medium">ویرایش</a>
|
||||
@if (post.IsPublished)
|
||||
{
|
||||
<a href="/blog/@post.EffectiveSlug" target="_blank"
|
||||
class="text-gray-400 hover:text-gray-600 text-xs">مشاهده</a>
|
||||
}
|
||||
<form method="post" asp-page-handler="Delete"
|
||||
onsubmit="return confirm('این مقاله حذف شود؟')" class="inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" value="@post.Id" />
|
||||
<button type="submit" class="text-red-400 hover:text-red-600 text-xs">حذف</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
using AsadiTools.Data;
|
||||
using AsadiTools.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AsadiTools.Pages.Admin.Blog;
|
||||
|
||||
[Authorize(AuthenticationSchemes = "AdminCookie")]
|
||||
public class AdminBlogIndexModel(AppDbContext db) : PageModel
|
||||
{
|
||||
public List<BlogPost> Posts { get; private set; } = [];
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
Posts = await db.BlogPosts.OrderByDescending(p => p.CreatedAt).ToListAsync();
|
||||
ViewData["Title"] = "مدیریت بلاگ";
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||
{
|
||||
var post = await db.BlogPosts.FindAsync(id);
|
||||
if (post is not null) { db.BlogPosts.Remove(post); await db.SaveChangesAsync(); }
|
||||
return RedirectToPage();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostTogglePublishAsync(int id)
|
||||
{
|
||||
var post = await db.BlogPosts.FindAsync(id);
|
||||
if (post is not null)
|
||||
{
|
||||
post.IsPublished = !post.IsPublished;
|
||||
if (post.IsPublished && post.PublishedAt is null)
|
||||
post.PublishedAt = DateTime.Now;
|
||||
post.UpdatedAt = DateTime.Now;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return RedirectToPage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
@model AsadiTools.Pages.Admin.Blog.BlogPostInput
|
||||
|
||||
@{
|
||||
var cls = "w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500";
|
||||
}
|
||||
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">عنوان مقاله <span class="text-red-500">*</span></label>
|
||||
<input asp-for="Title" class="@cls" placeholder="مثال: راهنمای تعمیر فرز برقی" />
|
||||
<span asp-validation-for="Title" class="text-red-500 text-xs"></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">اسلاگ (URL)</label>
|
||||
<input asp-for="Slug" class="@cls" dir="ltr" placeholder="carbon-brush-guide (خودکار از عنوان)" />
|
||||
<p class="text-xs text-gray-400 mt-1">URL صفحه: /blog/اسلاگ — اگر خالی بماند از عنوان ساخته میشود</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">چکیده (برای لیست بلاگ)</label>
|
||||
<textarea asp-for="Excerpt" rows="2" class="@cls resize-none" placeholder="خلاصه کوتاه مقاله..."></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">توضیح متا (SEO)</label>
|
||||
<textarea asp-for="MetaDescription" rows="2" class="@cls resize-none" placeholder="توضیح برای گوگل (حداکثر ۱۶۰ کاراکتر)..."></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">آدرس تصویر شاخص (URL)</label>
|
||||
<input asp-for="FeaturedImage" class="@cls" dir="ltr" placeholder="https://..." />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">برچسبها (با کاما جدا شوند)</label>
|
||||
<input asp-for="Tags" class="@cls" placeholder="تعمیر فرز,کاربن,دیوالت" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">محتوا (HTML) <span class="text-red-500">*</span></label>
|
||||
<textarea asp-for="Content" rows="20" class="@cls resize-y font-mono text-xs leading-relaxed" dir="auto"
|
||||
placeholder="<h2>عنوان بخش</h2> <p>متن مقاله...</p>"></textarea>
|
||||
<p class="text-xs text-gray-400 mt-1">محتوا به صورت HTML نوشته شود. از تگهای h2، h3، p، ul، li، strong استفاده کنید.</p>
|
||||
<span asp-validation-for="Content" class="text-red-500 text-xs"></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-4 bg-gray-50 rounded-xl border border-gray-100">
|
||||
<input asp-for="IsPublished" type="checkbox" class="w-4 h-4 rounded accent-blue-600" />
|
||||
<div>
|
||||
<label asp-for="IsPublished" class="font-medium text-gray-800 text-sm cursor-pointer">انتشار فوری</label>
|
||||
<p class="text-xs text-gray-400">اگر تیک بزنید، مقاله بلافاصله در سایت نمایش داده میشود.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.ChangePassword.ChangePasswordModel
|
||||
@{ ViewData["Title"] = "تغییر رمز عبور"; Layout = "_AdminLayout"; }
|
||||
|
||||
@{
|
||||
var inputCls = "w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500";
|
||||
}
|
||||
|
||||
<div class="p-6 md:p-8 max-w-lg">
|
||||
<h1 class="text-2xl font-extrabold text-gray-900 mb-8">تغییر رمز عبور</h1>
|
||||
|
||||
@if (Model.Success)
|
||||
{
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 px-5 py-4 rounded-xl mb-6 flex items-center gap-2">
|
||||
✅ رمز عبور با موفقیت تغییر یافت.
|
||||
</div>
|
||||
}
|
||||
|
||||
<form method="post" class="bg-white rounded-2xl border border-gray-100 p-6 space-y-5">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-xl">@Model.ErrorMessage</div>
|
||||
}
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">رمز عبور فعلی <span class="text-red-500">*</span></label>
|
||||
<input asp-for="Input.CurrentPassword" type="password" class="@inputCls" />
|
||||
<span asp-validation-for="Input.CurrentPassword" class="text-red-500 text-xs"></span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">رمز عبور جدید <span class="text-red-500">*</span></label>
|
||||
<input asp-for="Input.NewPassword" type="password" class="@inputCls" />
|
||||
<span asp-validation-for="Input.NewPassword" class="text-red-500 text-xs"></span>
|
||||
<p class="text-xs text-gray-400 mt-1">حداقل ۶ کاراکتر</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">تکرار رمز عبور جدید <span class="text-red-500">*</span></label>
|
||||
<input asp-for="Input.ConfirmPassword" type="password" class="@inputCls" />
|
||||
<span asp-validation-for="Input.ConfirmPassword" class="text-red-500 text-xs"></span>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full bg-blue-700 text-white py-3 rounded-xl font-bold hover:bg-blue-800 transition-colors">
|
||||
🔑 تغییر رمز عبور
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
using AsadiTools.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace AsadiTools.Pages.Admin.ChangePassword;
|
||||
|
||||
[Authorize(AuthenticationSchemes = "AdminCookie")]
|
||||
public class ChangePasswordModel(AppDbContext db) : PageModel
|
||||
{
|
||||
[BindProperty] public ChangePasswordInput Input { get; set; } = new();
|
||||
public string? ErrorMessage { get; private set; }
|
||||
public bool Success { get; private set; }
|
||||
|
||||
public void OnGet() { }
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
if (!ModelState.IsValid) return Page();
|
||||
|
||||
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
var user = await db.AdminUsers.FindAsync(userId);
|
||||
if (user is null) return RedirectToPage("/Admin/Login");
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(Input.CurrentPassword, user.PasswordHash))
|
||||
{
|
||||
ErrorMessage = "رمز عبور فعلی اشتباه است";
|
||||
return Page();
|
||||
}
|
||||
|
||||
if (Input.NewPassword != Input.ConfirmPassword)
|
||||
{
|
||||
ErrorMessage = "رمز عبور جدید و تکرار آن یکسان نیستند";
|
||||
return Page();
|
||||
}
|
||||
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Input.NewPassword);
|
||||
await db.SaveChangesAsync();
|
||||
Success = true;
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangePasswordInput
|
||||
{
|
||||
[Required] public string CurrentPassword { get; set; } = string.Empty;
|
||||
[Required, MinLength(6)] public string NewPassword { get; set; } = string.Empty;
|
||||
[Required] public string ConfirmPassword { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.AdminIndexModel
|
||||
@{ ViewData["Title"] = "داشبورد"; Layout = "_AdminLayout"; }
|
||||
|
||||
<div class="p-6 md:p-8">
|
||||
<h1 class="text-2xl font-extrabold text-gray-900 mb-8">داشبورد</h1>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-10">
|
||||
<div class="bg-white rounded-2xl border border-gray-100 p-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-blue-100 text-blue-600 flex items-center justify-center text-xl mb-3">🛍️</div>
|
||||
<div class="text-2xl font-extrabold text-gray-900 mb-0.5">@Model.TotalOrders</div>
|
||||
<div class="text-sm text-gray-500">کل سفارشها</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 p-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-yellow-100 text-yellow-600 flex items-center justify-center text-xl mb-3">⏳</div>
|
||||
<div class="text-2xl font-extrabold text-gray-900 mb-0.5">@Model.PendingOrders</div>
|
||||
<div class="text-sm text-gray-500">در انتظار تأیید</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 p-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-green-100 text-green-600 flex items-center justify-center text-xl mb-3">💰</div>
|
||||
<div class="text-lg font-extrabold text-gray-900 mb-0.5">@SiteData.FormatPrice(Model.TotalRevenue)</div>
|
||||
<div class="text-sm text-gray-500">درآمد کل</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 p-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-purple-100 text-purple-600 flex items-center justify-center text-xl mb-3">📦</div>
|
||||
<div class="text-2xl font-extrabold text-gray-900 mb-0.5">@Model.ActiveProducts</div>
|
||||
<div class="text-sm text-gray-500">محصولات فعال</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent orders -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100 p-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="font-bold text-gray-900">آخرین سفارشها</h2>
|
||||
<a href="/Admin/Orders" class="text-sm text-blue-600 hover:underline">مشاهده همه</a>
|
||||
</div>
|
||||
@if (!Model.RecentOrders.Any())
|
||||
{
|
||||
<p class="text-gray-400 text-sm text-center py-8">هنوز سفارشی ثبت نشده</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-gray-500">
|
||||
<th class="pb-3 text-right font-medium">شماره</th>
|
||||
<th class="pb-3 text-right font-medium">مشتری</th>
|
||||
<th class="pb-3 text-right font-medium">مبلغ</th>
|
||||
<th class="pb-3 text-right font-medium">وضعیت</th>
|
||||
<th class="pb-3 text-right font-medium">تاریخ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@foreach (var o in Model.RecentOrders)
|
||||
{
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="py-3 font-mono text-xs text-gray-500">@o.OrderNumber</td>
|
||||
<td class="py-3 font-medium">@o.CustomerName</td>
|
||||
<td class="py-3 text-blue-700 font-bold">@SiteData.FormatPrice(o.Total)</td>
|
||||
<td class="py-3">
|
||||
<span class="text-xs px-2 py-1 rounded-full font-medium @SiteData.OrderStatusBadge(o.Status)">
|
||||
@SiteData.OrderStatusLabel(o.Status)
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-3 text-gray-400 text-xs">@SiteData.ToJalali(o.CreatedAt)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
using AsadiTools.Data;
|
||||
using AsadiTools.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AsadiTools.Pages.Admin;
|
||||
|
||||
[Authorize(AuthenticationSchemes = "AdminCookie")]
|
||||
public class AdminIndexModel(AppDbContext db) : PageModel
|
||||
{
|
||||
public int TotalOrders { get; private set; }
|
||||
public int PendingOrders { get; private set; }
|
||||
public decimal TotalRevenue { get; private set; }
|
||||
public int ActiveProducts { get; private set; }
|
||||
public List<Order> RecentOrders { get; private set; } = [];
|
||||
|
||||
public async Task<IActionResult> OnGetAsync()
|
||||
{
|
||||
TotalOrders = await db.Orders.CountAsync();
|
||||
PendingOrders = await db.Orders.CountAsync(o => o.Status == OrderStatus.Pending);
|
||||
TotalRevenue = await db.Orders.Where(o => o.Status != OrderStatus.Cancelled).SumAsync(o => o.Total);
|
||||
ActiveProducts = await db.Products.CountAsync(p => p.IsActive);
|
||||
RecentOrders = await db.Orders.OrderByDescending(o => o.Id).Take(6).ToListAsync();
|
||||
return Page();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.LoginModel
|
||||
@{ ViewData["Title"] = "ورود به پنل مدیریت"; Layout = null; }
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ورود | پنل مدیریت آساد ابزار</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
@@font-face { font-family:"Vazirmatn"; src:url("https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@@v33.003/fonts/webfonts/Vazirmatn-Regular.woff2") format("woff2"); font-display:swap; }
|
||||
@@font-face { font-family:"Vazirmatn"; src:url("https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@@v33.003/fonts/webfonts/Vazirmatn-Bold.woff2") format("woff2"); font-weight:700; font-display:swap; }
|
||||
* { font-family:"Vazirmatn",Tahoma,sans-serif !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="text-center mb-8">
|
||||
<div class="bg-blue-700 text-white rounded-2xl p-4 inline-flex text-2xl mb-4">🔧</div>
|
||||
<h1 class="text-2xl font-extrabold text-gray-900">پنل مدیریت</h1>
|
||||
<p class="text-gray-500 text-sm mt-1">آساد ابزار کرج</p>
|
||||
</div>
|
||||
<form method="post" class="bg-white rounded-2xl shadow-sm border border-gray-100 p-8 space-y-5">
|
||||
@Html.AntiForgeryToken()
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">نام کاربری</label>
|
||||
<input asp-for="Input.Username" dir="ltr" placeholder="admin"
|
||||
class="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">رمز عبور</label>
|
||||
<input asp-for="Input.Password" type="password" dir="ltr" placeholder="••••••••"
|
||||
class="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
|
||||
{
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 text-sm px-4 py-3 rounded-xl">@Model.ErrorMessage</div>
|
||||
}
|
||||
<button type="submit" class="w-full bg-blue-700 text-white py-3.5 rounded-xl font-bold hover:bg-blue-800 transition-colors">
|
||||
🔒 ورود
|
||||
</button>
|
||||
<p class="text-xs text-gray-400 text-center">رمز پیشفرض: admin / admin1234</p>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
using AsadiTools.Data;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace AsadiTools.Pages.Admin;
|
||||
|
||||
public class LoginModel(AppDbContext db) : PageModel
|
||||
{
|
||||
[BindProperty]
|
||||
public InputModel Input { get; set; } = new();
|
||||
public string? ErrorMessage { get; private set; }
|
||||
|
||||
public class InputModel
|
||||
{
|
||||
[Required] public string Username { get; set; } = string.Empty;
|
||||
[Required] public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public IActionResult OnGet()
|
||||
{
|
||||
if (User.Identity?.IsAuthenticated == true)
|
||||
return RedirectToPage("/Admin/Index");
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
if (!ModelState.IsValid) return Page();
|
||||
|
||||
var user = db.AdminUsers.FirstOrDefault(u => u.Username == Input.Username);
|
||||
if (user is null || !BCrypt.Net.BCrypt.Verify(Input.Password, user.PasswordHash))
|
||||
{
|
||||
ErrorMessage = "نام کاربری یا رمز اشتباه است";
|
||||
return Page();
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.Name, user.Username),
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, "AdminCookie");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await HttpContext.SignInAsync("AdminCookie", principal);
|
||||
return RedirectToPage("/Admin/Index");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.LogoutModel
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace AsadiTools.Pages.Admin;
|
||||
|
||||
public class LogoutModel : PageModel
|
||||
{
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
await HttpContext.SignOutAsync("AdminCookie");
|
||||
return RedirectToPage("/Admin/Login");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.Orders.OrdersIndexModel
|
||||
@{ ViewData["Title"] = "سفارشها"; Layout = "_AdminLayout"; }
|
||||
|
||||
<div class="p-6 md:p-8">
|
||||
<h1 class="text-2xl font-extrabold text-gray-900 mb-8">سفارشها</h1>
|
||||
|
||||
@if (!Model.Orders.Any())
|
||||
{
|
||||
<div class="text-center py-20 text-gray-400">
|
||||
<div class="text-5xl mb-4">📦</div>
|
||||
<p>هنوز سفارشی ثبت نشده</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="space-y-4">
|
||||
@foreach (var o in Model.Orders)
|
||||
{
|
||||
<div class="bg-white rounded-2xl border border-gray-100 p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="font-mono text-sm text-gray-500">@o.OrderNumber</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full font-medium @SiteData.OrderStatusBadge(o.Status)">
|
||||
@SiteData.OrderStatusLabel(o.Status)
|
||||
</span>
|
||||
</div>
|
||||
<div class="font-bold text-gray-900">@o.CustomerName</div>
|
||||
<div class="text-sm text-gray-500">@o.CustomerPhone</div>
|
||||
@if (o.CustomerAddress != null) { <div class="text-xs text-gray-400 mt-1">@o.CustomerAddress</div> }
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-xl font-extrabold text-blue-700">@SiteData.FormatPrice(o.Total)</div>
|
||||
<div class="text-xs text-gray-400 mt-1">@SiteData.ToJalaliWithTime(o.CreatedAt)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div class="bg-gray-50 rounded-xl p-4 mb-4">
|
||||
<p class="text-xs text-gray-500 font-bold mb-2">اقلام سفارش:</p>
|
||||
<ul class="space-y-1">
|
||||
@foreach (var item in o.Items)
|
||||
{
|
||||
<li class="flex justify-between text-sm">
|
||||
<span class="text-gray-700">@item.ProductNameFa × @item.Quantity</span>
|
||||
<span class="text-gray-500">@SiteData.FormatPrice(item.Subtotal)</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@if (o.Notes != null) { <p class="text-sm text-gray-500 mb-4">یادداشت: @o.Notes</p> }
|
||||
|
||||
<!-- Status update -->
|
||||
<form method="post" asp-page-handler="UpdateStatus" class="flex items-center gap-3">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" value="@o.Id" />
|
||||
<span class="text-sm text-gray-500 font-medium">وضعیت:</span>
|
||||
<select name="status" class="border border-gray-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@foreach (var s in Enum.GetValues<AsadiTools.Models.OrderStatus>())
|
||||
{
|
||||
<option value="@s" selected="@(o.Status == s)">@SiteData.OrderStatusLabel(s)</option>
|
||||
}
|
||||
</select>
|
||||
<button type="submit" class="bg-blue-700 text-white px-4 py-1.5 rounded-lg text-sm font-medium hover:bg-blue-800 transition-colors">
|
||||
ذخیره
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
using AsadiTools.Data;
|
||||
using AsadiTools.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AsadiTools.Pages.Admin.Orders;
|
||||
|
||||
[Authorize(AuthenticationSchemes = "AdminCookie")]
|
||||
public class OrdersIndexModel(AppDbContext db) : PageModel
|
||||
{
|
||||
public List<Order> Orders { get; private set; } = [];
|
||||
|
||||
public async Task OnGetAsync() =>
|
||||
Orders = await db.Orders
|
||||
.Include(o => o.Items)
|
||||
.OrderByDescending(o => o.Id)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<IActionResult> OnPostUpdateStatusAsync(int id, OrderStatus status)
|
||||
{
|
||||
var order = await db.Orders.FindAsync(id);
|
||||
if (order is not null)
|
||||
{
|
||||
order.Status = status;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return RedirectToPage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.Products.CreateModel
|
||||
@{ ViewData["Title"] = "افزودن محصول"; Layout = "_AdminLayout"; }
|
||||
|
||||
<div class="p-6 md:p-8 max-w-2xl">
|
||||
<h1 class="text-2xl font-extrabold text-gray-900 mb-8">افزودن محصول جدید</h1>
|
||||
<form method="post" class="bg-white rounded-2xl border border-gray-100 p-6 space-y-5">
|
||||
@Html.AntiForgeryToken()
|
||||
@await Html.PartialAsync("_ProductFormFields", Model.Input)
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="flex-1 bg-blue-700 text-white py-3 rounded-xl font-bold hover:bg-blue-800 transition-colors">افزودن محصول</button>
|
||||
<a href="/Admin/Products" class="px-5 border border-gray-200 rounded-xl text-sm text-gray-600 hover:bg-gray-50 transition-colors flex items-center">انصراف</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,52 @@
|
||||
using AsadiTools.Data;
|
||||
using AsadiTools.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace AsadiTools.Pages.Admin.Products;
|
||||
|
||||
[Authorize(AuthenticationSchemes = "AdminCookie")]
|
||||
public class CreateModel(AppDbContext db) : PageModel
|
||||
{
|
||||
[BindProperty] public ProductInput Input { get; set; } = new();
|
||||
|
||||
public void OnGet() { }
|
||||
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
if (!ModelState.IsValid) return Page();
|
||||
db.Products.Add(new Product
|
||||
{
|
||||
NameFa = Input.NameFa,
|
||||
NameEn = Input.NameEn,
|
||||
Description = Input.Description,
|
||||
Price = Input.Price,
|
||||
DiscountPrice = Input.DiscountPrice,
|
||||
Category = Input.Category,
|
||||
Brand = string.IsNullOrEmpty(Input.Brand) ? null : Input.Brand,
|
||||
Sku = Input.Sku,
|
||||
Stock = Input.Stock,
|
||||
IsActive = Input.IsActive,
|
||||
ImageUrl = string.IsNullOrWhiteSpace(Input.ImageUrl) ? null : Input.ImageUrl,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return RedirectToPage("/Admin/Products/Index");
|
||||
}
|
||||
}
|
||||
|
||||
public class ProductInput
|
||||
{
|
||||
[Required] public string NameFa { get; set; } = string.Empty;
|
||||
public string? NameEn { get; set; }
|
||||
public string? Description { get; set; }
|
||||
[Required, Range(1, int.MaxValue)] public decimal Price { get; set; }
|
||||
public decimal? DiscountPrice { get; set; }
|
||||
[Required] public string Category { get; set; } = "carbon";
|
||||
public string? Brand { get; set; }
|
||||
public string? Sku { get; set; }
|
||||
public int Stock { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string? ImageUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.Products.EditModel
|
||||
@{ ViewData["Title"] = "ویرایش محصول"; Layout = "_AdminLayout"; }
|
||||
|
||||
<div class="p-6 md:p-8 max-w-2xl">
|
||||
<h1 class="text-2xl font-extrabold text-gray-900 mb-8">ویرایش محصول</h1>
|
||||
<form method="post" class="bg-white rounded-2xl border border-gray-100 p-6 space-y-5">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" value="@Model.ProductId" />
|
||||
@await Html.PartialAsync("_ProductFormFields", Model.Input)
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="flex-1 bg-blue-700 text-white py-3 rounded-xl font-bold hover:bg-blue-800 transition-colors">ذخیره تغییرات</button>
|
||||
<a href="/Admin/Products" class="px-5 border border-gray-200 rounded-xl text-sm text-gray-600 hover:bg-gray-50 transition-colors flex items-center">انصراف</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
using AsadiTools.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace AsadiTools.Pages.Admin.Products;
|
||||
|
||||
[Authorize(AuthenticationSchemes = "AdminCookie")]
|
||||
public class EditModel(AppDbContext db) : PageModel
|
||||
{
|
||||
[BindProperty] public ProductInput Input { get; set; } = new();
|
||||
public int ProductId { get; private set; }
|
||||
|
||||
public async Task<IActionResult> OnGetAsync(int id)
|
||||
{
|
||||
var p = await db.Products.FindAsync(id);
|
||||
if (p is null) return NotFound();
|
||||
ProductId = id;
|
||||
Input = new ProductInput
|
||||
{
|
||||
NameFa = p.NameFa,
|
||||
NameEn = p.NameEn,
|
||||
Description = p.Description,
|
||||
Price = p.Price,
|
||||
DiscountPrice = p.DiscountPrice,
|
||||
Category = p.Category,
|
||||
Brand = p.Brand,
|
||||
Sku = p.Sku,
|
||||
Stock = p.Stock,
|
||||
IsActive = p.IsActive,
|
||||
ImageUrl = p.ImageUrl,
|
||||
};
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostAsync(int id)
|
||||
{
|
||||
if (!ModelState.IsValid) { ProductId = id; return Page(); }
|
||||
var p = await db.Products.FindAsync(id);
|
||||
if (p is null) return NotFound();
|
||||
|
||||
p.NameFa = Input.NameFa;
|
||||
p.NameEn = Input.NameEn;
|
||||
p.Description = Input.Description;
|
||||
p.Price = Input.Price;
|
||||
p.DiscountPrice = Input.DiscountPrice;
|
||||
p.Category = Input.Category;
|
||||
p.Brand = string.IsNullOrEmpty(Input.Brand) ? null : Input.Brand;
|
||||
p.Sku = Input.Sku;
|
||||
p.Stock = Input.Stock;
|
||||
p.IsActive = Input.IsActive;
|
||||
p.ImageUrl = string.IsNullOrWhiteSpace(Input.ImageUrl) ? null : Input.ImageUrl;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return RedirectToPage("/Admin/Products/Index");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@page
|
||||
@model AsadiTools.Pages.Admin.Products.ProductsIndexModel
|
||||
@{ ViewData["Title"] = "محصولات"; Layout = "_AdminLayout"; }
|
||||
|
||||
<div class="p-6 md:p-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-extrabold text-gray-900">محصولات</h1>
|
||||
<a href="/Admin/Products/Create" class="flex items-center gap-2 bg-blue-700 text-white px-4 py-2.5 rounded-xl text-sm font-bold hover:bg-blue-800 transition-colors">
|
||||
➕ افزودن محصول
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th class="px-5 py-3.5 text-right font-medium text-gray-500">نام</th>
|
||||
<th class="px-5 py-3.5 text-right font-medium text-gray-500">دسته</th>
|
||||
<th class="px-5 py-3.5 text-right font-medium text-gray-500">برند</th>
|
||||
<th class="px-5 py-3.5 text-right font-medium text-gray-500">قیمت</th>
|
||||
<th class="px-5 py-3.5 text-right font-medium text-gray-500">موجودی</th>
|
||||
<th class="px-5 py-3.5 text-right font-medium text-gray-500">وضعیت</th>
|
||||
<th class="px-5 py-3.5 text-right font-medium text-gray-500">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
@foreach (var p in Model.Products)
|
||||
{
|
||||
var cat = SiteData.Categories.FirstOrDefault(c => c.Id == p.Category);
|
||||
var brand = SiteData.Brands.FirstOrDefault(b => b.Id == p.Brand);
|
||||
<tr class="hover:bg-gray-50/50">
|
||||
<td class="px-5 py-4">
|
||||
<div class="font-medium text-gray-900">@p.NameFa</div>
|
||||
@if (p.Sku != null) { <div class="text-xs text-gray-400 font-mono">@p.Sku</div> }
|
||||
</td>
|
||||
<td class="px-5 py-4 text-gray-500">@(cat != null ? cat.Icon + " " + cat.NameFa : p.Category)</td>
|
||||
<td class="px-5 py-4">
|
||||
@if (brand != null)
|
||||
{
|
||||
<span class="text-xs font-bold px-2 py-0.5 rounded-full text-white" style="background-color:@brand.Color">@brand.NameFa</span>
|
||||
}
|
||||
else { <span class="text-gray-400">–</span> }
|
||||
</td>
|
||||
<td class="px-5 py-4 font-bold text-blue-700">@SiteData.FormatPrice(p.Price)</td>
|
||||
<td class="px-5 py-4">
|
||||
<span class="font-bold @(p.Stock == 0 ? "text-red-500" : p.Stock < 5 ? "text-yellow-500" : "text-green-600")">@p.Stock</span>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<span class="text-xs px-2 py-1 rounded-full font-medium @(p.IsActive ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500")">
|
||||
@(p.IsActive ? "فعال" : "غیرفعال")
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/Admin/Products/Edit?id=@p.Id" class="text-blue-600 hover:text-blue-800 text-xs font-medium">ویرایش</a>
|
||||
<form method="post" asp-page-handler="Delete" onsubmit="return confirm('حذف شود؟')">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" value="@p.Id" />
|
||||
<button type="submit" class="text-red-400 hover:text-red-600 text-xs font-medium">حذف</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
using AsadiTools.Data;
|
||||
using AsadiTools.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AsadiTools.Pages.Admin.Products;
|
||||
|
||||
[Authorize(AuthenticationSchemes = "AdminCookie")]
|
||||
public class ProductsIndexModel(AppDbContext db) : PageModel
|
||||
{
|
||||
public List<Product> Products { get; private set; } = [];
|
||||
|
||||
public async Task OnGetAsync() =>
|
||||
Products = await db.Products.OrderByDescending(p => p.Id).ToListAsync();
|
||||
|
||||
public async Task<IActionResult> OnPostDeleteAsync(int id)
|
||||
{
|
||||
var p = await db.Products.FindAsync(id);
|
||||
if (p is not null) { p.IsActive = false; await db.SaveChangesAsync(); }
|
||||
return RedirectToPage();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@model AsadiTools.Pages.Admin.Products.ProductInput
|
||||
@using AsadiTools.Services
|
||||
|
||||
@{
|
||||
var inputCls = "w-full border border-gray-200 rounded-xl px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500";
|
||||
}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">نام فارسی <span class="text-red-500">*</span></label>
|
||||
<input asp-for="NameFa" class="@inputCls" placeholder="مثال: کاربن دیوالت DCD776" />
|
||||
<span asp-validation-for="NameFa" class="text-red-500 text-xs"></span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">نام انگلیسی</label>
|
||||
<input asp-for="NameEn" class="@inputCls" dir="ltr" placeholder="e.g. DeWalt DCD776 Carbon Brush" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">کد محصول (SKU)</label>
|
||||
<input asp-for="Sku" class="@inputCls" dir="ltr" placeholder="DW-CBR-776" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">دستهبندی <span class="text-red-500">*</span></label>
|
||||
<select asp-for="Category" class="@inputCls">
|
||||
@foreach (var c in SiteData.Categories)
|
||||
{
|
||||
<option value="@c.Id">@c.Icon @c.NameFa</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">برند</label>
|
||||
<select asp-for="Brand" class="@inputCls">
|
||||
<option value="">بدون برند</option>
|
||||
@foreach (var b in SiteData.Brands)
|
||||
{
|
||||
<option value="@b.Id">@b.NameFa</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">موجودی</label>
|
||||
<input asp-for="Stock" type="number" min="0" class="@inputCls" dir="ltr" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">قیمت (تومان) <span class="text-red-500">*</span></label>
|
||||
<input asp-for="Price" type="number" min="0" class="@inputCls" dir="ltr" />
|
||||
<span asp-validation-for="Price" class="text-red-500 text-xs"></span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">قیمت با تخفیف</label>
|
||||
<input asp-for="DiscountPrice" type="number" min="0" class="@inputCls" dir="ltr" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">وضعیت</label>
|
||||
<select asp-for="IsActive" class="@inputCls">
|
||||
<option value="true">فعال</option>
|
||||
<option value="false">غیرفعال</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">توضیحات</label>
|
||||
<textarea asp-for="Description" rows="3" class="@inputCls resize-none"></textarea>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700 mb-1.5 block">آدرس تصویر (URL)</label>
|
||||
<input asp-for="ImageUrl" class="@inputCls" dir="ltr" placeholder="https://..." />
|
||||
<p class="text-xs text-gray-400 mt-1">لینک مستقیم به تصویر محصول. در صورت خالی ماندن آیکون دستهبندی نمایش داده میشود.</p>
|
||||
@if (!string.IsNullOrEmpty(Model?.ImageUrl))
|
||||
{
|
||||
<img src="@Model.ImageUrl" alt="پیشنمایش" class="mt-2 h-24 w-24 object-cover rounded-xl border border-gray-200" onerror="this.style.display='none'" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user