Initial commit — AsadiTools v1.0
CI/CD / CI — dotnet build (push) Successful in 44s
CI/CD / Deploy — docker compose (push) Failing after 1s

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:
Soroush Asadi
2026-06-01 22:08:43 +03:30
commit f97f891d67
146 changed files with 88128 additions and 0 deletions
+22
View File
@@ -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>
+58
View File
@@ -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; }
}
+23
View File
@@ -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>
+57
View File
@@ -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");
}
}
+86
View File
@@ -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>
+41
View File
@@ -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();
}
}
+55
View File
@@ -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>&#10;<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>
+50
View File
@@ -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;
}
+75
View File
@@ -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>
+28
View File
@@ -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();
}
}
+47
View File
@@ -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>
+51
View File
@@ -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");
}
}
+2
View File
@@ -0,0 +1,2 @@
@page
@model AsadiTools.Pages.Admin.LogoutModel
+14
View File
@@ -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");
}
}
+74
View File
@@ -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>
+31
View File
@@ -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();
}
}
+15
View File
@@ -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>
+52
View File
@@ -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; }
}
+16
View File
@@ -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>
+57
View File
@@ -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");
}
}
+70
View File
@@ -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>
+24
View File
@@ -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>