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
+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>