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