• 1405/07/19

افزودن محصول به همراه ویژگی، رنگ و گالری تصاویر :

سلام و عرض ادب خدمت استاد گرامی و دوستان عزیزم 

من بخش افزودن محصول رو با کمک هوش مصنوعی طراحی و پیاده‌سازی کردم و تصاویرش رو در ادامه قرار می‌دم.

از اونجایی که سعی کردم طراحی این بخش کاربرپسند باشه و فرایند ثبت محصول رو ساده‌تر کنه، گفتم اون رو با دوستان به اشتراک بذارم تا اگر کسی تمایل داشت، بتونه ازش ایده بگیره یا در پروژه خودش استفاده کنه.

البته در پیاده‌سازی این بخش از JavaScript هم استفاده شده؛ بنابراین دوستانی که تسلط کافی به JavaScript ندارند، می‌تونند طبق روشی که استاد گرامی آموزش دادند، کارشون رو پیش ببرند.

امیدوارم براتون مفید باشه.

ویرایش و حذف رو توی یک صفحه انجام دادم که صفحات بیشتری نداشته باشیم. یک view به اسم CreateEdit ایجاد کردم و همه تب ها رو توی اون قرار دادم البته که میتونین هر کدوم از صفحات رو به صورت Partial طراحی کنین.

1. کدهای صفحه CreateEdit



@using Domain.ViewModels.Product
@using System.Text.Json
@model AdminCreateProductViewModel

@{
    ViewData["Title"] = Model.IsNew ? "افزودن محصول" : "ویرایش محصول";
    var editorConfig = new
    {
        productId = Model.ProductId ?? 0,
        categoryTree = Model.CategoryTree,
        selectedCategoryId = Model.CategoryId,
        colors = Model.AllColors,
        selectedTags = Model.SelectedTags,
        urls = new
        {
            index = Url.Action("Index", "Products", new { area = "Admin" }),
            getTags = Url.Action("GetTags", "Products", new { area = "Admin" }),
            saveBasicInfo = Url.Action("SaveBasicInfo", "Products", new { area = "Admin" }),
            createColor = Url.Action("CreateColor", "Products", new { area = "Admin" }),
            addColor = Url.Action("AddColor", "Products", new { area = "Admin" }),
            removeColor = Url.Action("RemoveColor", "Products", new { area = "Admin" }),
            addGallery = Url.Action("AddGallery", "Products", new { area = "Admin" }),
            removeGallery = Url.Action("RemoveGallery", "Products", new { area = "Admin" }),
            addFeature = Url.Action("AddFeature", "Products", new { area = "Admin" }),
            removeFeature = Url.Action("RemoveFeature", "Products", new { area = "Admin" }),
            createTag = Url.Action("CreateTag", "Products", new { area = "Admin" })
        }
    };
    var editorConfigJson = JsonSerializer.Serialize(
        editorConfig,
        new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
    var productImage = string.IsNullOrWhiteSpace(Model.ImageName)
        ? Url.Content("~/Uploads/UserProfile/NoPhoto.jpg")
        : Url.Content("~/Uploads/Products/" + Model.ImageName);
}

<link rel="stylesheet" href="~/Admin/css/product-editor.css" asp-append-version="true" />


<div class="product-page" dir="rtl">
    <div class="page-heading">
        <div>
            <div class="eyebrow">مدیریت فروشگاه / محصولات</div>
            <h1 class="page-title">@(Model.IsNew ? "افزودن محصول جدید" : "ویرایش محصول")</h1>
            <p class="page-subtitle">اطلاعات محصول، تنوع رنگ، تصاویر و ویژگی‌ها را یکجا مدیریت کنید.</p>
        </div>
        <div class="heading-actions">
            <a asp-area="Admin" asp-controller="Products" asp-action="Index" class="btn soft-btn">
                <i class="bx bx-arrow-back me-1"></i>
                بازگشت به محصولات
            </a>
            <button type="button" id="btnTopSave" class="btn btn-primary">
                <i class="bx bx-save me-1"></i>
                ذخیره اطلاعات پایه
            </button>
        </div>
    </div>

    <div class="hero-card">
        <div class="hero-product">
            <div class="hero-product-icon"><i class="bx bx-package"></i></div>
            <div>
                <p class="hero-product-title" id="heroProductTitle">
                    @(Model.IsNew ? "محصول هنوز ذخیره نشده است" : Model.Title)
                </p>
                <p class="hero-product-meta" id="heroProductMeta">
                    @(Model.IsNew ? "برای فعال شدن بخش‌های دیگر، ابتدا اطلاعات پایه را ذخیره کنید." : "ویرایش محصول ثبت‌شده")
                </p>
            </div>
        </div>
        <span id="productStatusPill" class="status-pill @(Model.IsNew ? "is-draft" : "")">
            @(Model.IsNew ? "پیش‌نویس" : "فعال")
        </span>
    </div>

    <div class="stats-grid">
        <div class="stat-card">
            <div class="stat-icon"><i class="bx bx-palette"></i></div>
            <div>
                <p class="stat-label">تنوع رنگ</p>
                <p class="stat-value" id="colorsCount">@Model.Colors.Count</p>
            </div>
        </div>
        <div class="stat-card">
            <div class="stat-icon"><i class="bx bx-images"></i></div>
            <div>
                <p class="stat-label">تصاویر گالری</p>
                <p class="stat-value" id="galleryCount">@Model.Gallery.Count</p>
            </div>
        </div>
        <div class="stat-card">
            <div class="stat-icon"><i class="bx bx-list-ul"></i></div>
            <div>
                <p class="stat-label">ویژگی‌ها</p>
                <p class="stat-value" id="featuresCount">@Model.Features.Count</p>
            </div>
        </div>
        <div class="stat-card">
            <div class="stat-icon"><i class="bx bx-purchase-tag-alt"></i></div>
            <div>
                <p class="stat-label">قیمت پایه</p>
                <p class="stat-value" id="basePricePreview">@Model.Price.ToString("N0") تومان</p>
            </div>
        </div>
    </div>

    <form id="productAntiForgery">@Html.AntiForgeryToken()</form>
    <input type="hidden" id="productId" value="@(Model.ProductId ?? 0)" />

    <div id="globalProductAlert" class="product-alert" role="alert"></div>

    @if (Model.IsNew)
    {
        <div id="newProductHint" class="alert alert-info d-flex align-items-center gap-2 mb-3">
            <i class="bx bx-info-circle fs-5"></i>
            <span>ابتدا اطلاعات پایه را ذخیره کنید؛ سپس تب‌های رنگ، گالری و ویژگی برای شما فعال می‌شوند.</span>
        </div>
    }

    <div class="editor-card">
        <div class="product-tabs" role="tablist">
            <button type="button" class="product-tab active" data-tab-target="tab-basic">
                <span class="tab-number">۱</span>
                اطلاعات پایه
            </button>
            <button type="button" class="product-tab" data-tab-target="tab-colors" @(Model.IsNew ? "disabled" : "")>
                <span class="tab-number">۲</span>
                رنگ‌ها
            </button>
            <button type="button" class="product-tab" data-tab-target="tab-gallery" @(Model.IsNew ? "disabled" : "")>
                <span class="tab-number">۳</span>
                گالری تصاویر
            </button>
            <button type="button" class="product-tab" data-tab-target="tab-features" @(Model.IsNew ? "disabled" : "")>
                <span class="tab-number">۴</span>
                ویژگی‌ها
            </button>
        </div>

        <section id="tab-basic" class="tab-pane active" role="tabpanel">
            <div class="section-intro">
                <div>
                    <h2 class="section-title">مشخصات اصلی محصول</h2>
                    <p class="section-description">عنوان، دسته‌بندی، قیمت، وضعیت و توضیحات محصول را وارد کنید.</p>
                </div>
                <span class="badge bg-label-primary">مرحله ۱ از ۴</span>
            </div>

            <div id="basicInfoAlert" class="product-alert" role="alert"></div>
            @* <div asp-validation-summary="ModelOnly" class="product-alert" role="alert"></div> *@

            <div class="row g-4">
                <div class="col-lg-8">
                  
                    <div class="row g-3">
                        <div class="col-12">
                            <label class="form-label" for="Title">نام محصول <span class="text-danger">*</span></label>
                            <input id="Title" class="form-control" value="@Model.Title" placeholder="مثلا: ساعت هوشمند سامسونگ" />
                        </div>

                        <div class="col-md-6">
                            <label class="form-label" for="Price">قیمت پایه <span class="text-danger">*</span></label>
                            <div class="input-group">
                                <input id="Price" type="number" min="1" class="form-control" value="@(Model.Price > 0 ? Model.Price.ToString(System.Globalization.CultureInfo.InvariantCulture) : "")" placeholder="۰" />
                                <span class="input-group-text">تومان</span>
                            </div>
                        </div>

                        <div class="col-md-6">
                            <label class="form-label" for="Count">موجودی اولیه</label>
                            <input id="Count" type="number" min="0" class="form-control" value="@Model.Count" placeholder="۰" />
                        </div>

                        <div class="col-12">
                            <label class="form-label">دسته‌بندی محصول <span class="text-danger">*</span></label>
                            <div id="categoryContainer"></div>
                            <input type="hidden" id="CategoryId" value="@Model.CategoryId" />
                            <div class="field-hint">آخرین سطح انتخاب‌شده به‌عنوان دسته‌بندی محصول ثبت می‌شود.</div>
                        </div>

                        <div class="col-12">
                            <div class="d-flex align-items-center justify-content-between mb-2">
                                <label class="form-label mb-0">برچسب‌ها</label>
                            </div>
                            <div>
                                <input id="TagifyCustomInlineSuggestion" class="form-control" autocomplete="off" placeholder="برچسب را بنویسید و با Enter اضافه کنید..." />
                            </div>
                            <div class="field-hint">برچسب‌های موجود را انتخاب کنید یا با دکمه بالا مورد جدید بسازید.</div>
                        </div>

                        <div class="col-12">
                            <label class="form-label" for="ShortDiscription">توضیح کوتاه</label>
                            <textarea id="ShortDiscription" class="form-control" placeholder="یک معرفی کوتاه و کاربردی برای کارت محصول...">@Model.ShortDiscription</textarea>
                        </div>

                        <div class="col-md-6">
                            <label class="form-label" for="Review">نقد و بررسی</label>
                            <textarea id="Review" class="form-control" placeholder="نکات مهم و تجربه استفاده از محصول...">@Model.Review</textarea>
                        </div>

                        <div class="col-md-6">
                            <label class="form-label" for="DetailReview">بررسی تخصصی</label>
                            <textarea id="DetailReview" class="form-control" placeholder="مشخصات فنی و توضیحات کامل...">@Model.DetailReview</textarea>
                        </div>

                        <div class="col-12">
                            <div class="switch-line">
                                <div>
                                    <div class="form-check">
                                        <input id="IsActive" class="form-check-input" type="checkbox" @(Model.IsActive ? "checked" : "") />
                                        <label class="form-check-label" for="IsActive">نمایش محصول در فروشگاه</label>
                                    </div>
                                    <div class="field-hint mb-0 me-4">در صورت غیرفعال بودن، محصول در سایت نمایش داده نمی‌شود.</div>
                                </div>
                                <i class="bx bx-show-alt text-primary fs-4"></i>
                            </div>
                        </div>
                    </div>
                </div>

                <div class="col-lg-4">
                    <div class="image-uploader">
                        <label class="form-label">تصویر اصلی محصول</label>
                        <div class="image-preview">
                            <img id="productImagePreview" src="@productImage" alt="پیش‌نمایش تصویر محصول" />
                        </div>
                        <label class="upload-label" for="ImageFile">
                            <i class="bx bx-cloud-upload"></i>
                            انتخاب تصویر اصلی
                            <input id="ImageFile" type="file" accept="image/jpeg,image/png,image/webp,image/gif" />
                        </label>
                        <div class="field-hint">فرمت‌های مجاز: JPG، PNG، WEBP و GIF</div>
                    </div>
                </div>
            </div>
        </section>

        <section id="tab-colors" class="tab-pane" role="tabpanel">
            <div class="section-intro">
                <div>
                    <h2 class="section-title">تنوع رنگ و موجودی</h2>
                    <p class="section-description">رنگ را از لیست انتخاب کنید یا همان‌جا رنگ جدید بسازید.</p>
                </div>
                <span class="badge bg-label-primary">مرحله ۲ از ۴</span>
            </div>

            <div id="colorsAlert" class="product-alert" role="alert"></div>

            <div class="card border-0 bg-light-subtle mb-4">
                <div class="card-body">
                    <div class="row g-3 align-items-end">
                        <div class="col-xl-5">
                            <div class="d-flex align-items-center justify-content-between mb-2">
                                <label class="form-label mb-0" for="colorSelect">رنگ محصول</label>
                                <button type="button" class="inline-action" data-modal-open="createColorModal">
                                    <i class="bx bx-plus-circle"></i>
                                    رنگ جدید
                                </button>
                            </div>
                            <div class="input-group">
                                <span id="selectedColorSwatch" class="input-group-text bg-white">
                                    <span class="color-swatch" style="background-color:#64748b"></span>
                                </span>
                                <select id="colorSelect" class="form-select">
                                    <option value="">انتخاب رنگ از لیست</option>
                                    @foreach (var color in Model.AllColors)
                                    {
                                        <option value="@color.Id" data-code="@color.Code">@color.Name</option>
                                    }
                                </select>
                            </div>
                        </div>
                        <div class="col-md-4 col-xl-3">
                            <label class="form-label" for="colorPrice">قیمت این رنگ</label>
                            <div class="input-group">
                                <input id="colorPrice" type="number" min="0" class="form-control" placeholder="اختیاری" />
                                <span class="input-group-text">تومان</span>
                            </div>
                        </div>
                        <div class="col-md-4 col-xl-2">
                            <label class="form-label" for="colorQuantity">موجودی</label>
                            <input id="colorQuantity" type="number" min="0" class="form-control" value="0" />
                        </div>
                        <div class="col-md-4 col-xl-2">
                            <div class="switch-line mb-0">
                                <div class="form-check">
                                    <input id="colorIsDefault" class="form-check-input" type="checkbox" />
                                    <label class="form-check-label" for="colorIsDefault">پیش‌فرض</label>
                                </div>
                            </div>
                        </div>
                        <div class="col-12 d-flex justify-content-end">
                            <button type="button" id="btnAddColor" class="btn btn-primary">
                                <i class="bx bx-plus me-1"></i>
                                افزودن تنوع رنگ
                            </button>
                        </div>
                    </div>
                </div>
            </div>

            <div class="table-wrap">
                <table class="table" id="colorsTable">
                    <thead>
                        <tr>
                            <th>رنگ</th>
                            <th>قیمت اختصاصی</th>
                            <th>موجودی</th>
                            <th>وضعیت</th>
                            <th class="text-end">عملیات</th>
                        </tr>
                    </thead>
                    <tbody id="colorsBody">
                        @if (Model.Colors.Count == 0)
                        {
                            <tr id="colorsEmptyRow">
                                <td colspan="5" class="empty-state">
                                    <i class="bx bx-palette"></i>
                                    هنوز تنوع رنگی برای این محصول ثبت نشده است.
                                </td>
                            </tr>
                        }
                        @foreach (var color in Model.Colors)
                        {
                            <tr data-id="@color.Id" data-default="@color.IsDefault.ToString().ToLowerInvariant()">
                                <td>
                                    <span class="color-name">
                                        <span class="color-swatch" style="background-color:@color.ColorCode"></span>
                                        @color.ColorName
                                    </span>
                                </td>
                                <td class="muted-cell">@(color.Price > 0 ? color.Price.ToString("N0") + " تومان" : "قیمت پایه")</td>
                                <td>@color.Quantity.ToString("N0")</td>
                                <td>
                                    <span class="badge @(color.IsDefault ? "bg-label-success" : "bg-label-secondary")">
                                        @(color.IsDefault ? "رنگ پیش‌فرض" : "فعال")
                                    </span>
                                </td>
                                <td class="text-end">
                                    <button type="button" class="danger-btn js-remove-color" data-id="@color.Id">
                                        <i class="bx bx-trash"></i>
                                        حذف
                                    </button>
                                </td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>
        </section>

        <section id="tab-gallery" class="tab-pane" role="tabpanel">
            <div class="section-intro">
                <div>
                    <h2 class="section-title">گالری تصاویر</h2>
                    <p class="section-description">چند تصویر انتخاب کنید، پیش‌نمایش را ببینید و همه را به گالری محصول اضافه کنید.</p>
                </div>
                <span class="badge bg-label-primary">مرحله ۳ از ۴</span>
            </div>

            <div id="galleryAlert" class="product-alert" role="alert"></div>

            <div class="gallery-dropzone">
                <div class="gallery-dropzone-icon"><i class="bx bx-images"></i></div>
                <div class="gallery-dropzone-copy">
                    <strong>تصاویر گالری را انتخاب کنید</strong>
                    <span>امکان انتخاب هم‌زمان چند تصویر وجود دارد.</span>
                </div>
                <label class="btn btn-outline-primary mb-0" for="galleryFiles">
                    انتخاب تصاویر
                    <input id="galleryFiles" type="file" accept="image/jpeg,image/png,image/webp,image/gif" multiple hidden />
                </label>
            </div>

            <div class="row g-3 align-items-end mt-1">
                <div class="col-md-8">
                    <label class="form-label" for="galleryAlt">متن جایگزین تصاویر</label>
                    <input id="galleryAlt" class="form-control" placeholder="مثلا: نمای روبه‌روی محصول" />
                </div>
                <div class="col-md-4">
                    <button type="button" id="btnAddGallery" class="btn btn-primary w-100">
                        <i class="bx bx-upload me-1"></i>
                        افزودن تصاویر انتخاب‌شده
                    </button>
                </div>
            </div>

            <div id="pendingGallery" class="gallery-grid"></div>
            <div id="galleryGrid" class="gallery-grid">
                @if (Model.Gallery.Count == 0)
                {
                    <div id="galleryEmptyState" class="empty-state border rounded-3 w-100" style="grid-column:1/-1;">
                        <i class="bx bx-image-alt"></i>
                        هنوز تصویری به گالری اضافه نشده است.
                    </div>
                }
                @foreach (var image in Model.Gallery)
                {
                    <div class="gallery-card" data-id="@image.Id">
                        <img src="/Uploads/Products/@image.ImageName" alt="@image.Alt" />
                        <div class="gallery-card-body">
                            <small title="@image.Alt">@image.Alt</small>
                            <button type="button" class="danger-btn js-remove-gallery" data-id="@image.Id">
                                <i class="bx bx-trash"></i>
                                حذف تصویر
                            </button>
                        </div>
                    </div>
                }
            </div>
        </section>

        <section id="tab-features" class="tab-pane" role="tabpanel">
            <div class="section-intro">
                <div>
                    <h2 class="section-title">ویژگی‌های محصول</h2>
                    <p class="section-description">مشخصات کلیدی محصول را به‌صورت نام و مقدار اضافه کنید.</p>
                </div>
                <span class="badge bg-label-primary">مرحله ۴ از ۴</span>
            </div>

            <div id="featuresAlert" class="product-alert" role="alert"></div>

            <div class="card border-0 bg-light-subtle mb-4">
                <div class="card-body">
                    <div class="row g-3 align-items-end">
                        <div class="col-md-5">
                            <label class="form-label" for="featureName">نام ویژگی</label>
                            <input id="featureName" class="form-control" placeholder="مثلا: جنس بدنه" />
                        </div>
                        <div class="col-md-5">
                            <label class="form-label" for="featureValue">مقدار ویژگی</label>
                            <input id="featureValue" class="form-control" placeholder="مثلا: آلومینیوم" />
                        </div>
                        <div class="col-md-2">
                            <button type="button" id="btnAddFeature" class="btn btn-primary w-100">
                                <i class="bx bx-plus me-1"></i>
                                افزودن
                            </button>
                        </div>
                    </div>
                </div>
            </div>

            <div class="table-wrap">
                <table class="table" id="featuresTable">
                    <thead>
                        <tr>
                            <th>نام ویژگی</th>
                            <th>مقدار</th>
                            <th class="text-end">عملیات</th>
                        </tr>
                    </thead>
                    <tbody id="featuresBody">
                        @if (Model.Features.Count == 0)
                        {
                            <tr id="featuresEmptyRow">
                                <td colspan="3" class="empty-state">
                                    <i class="bx bx-list-ul"></i>
                                    هنوز ویژگی‌ای برای این محصول ثبت نشده است.
                                </td>
                            </tr>
                        }
                        @foreach (var feature in Model.Features)
                        {
                            <tr data-id="@feature.Id">
                                <td class="fw-semibold">@feature.Name</td>
                                <td class="muted-cell">@feature.Value</td>
                                <td class="text-end">
                                    <button type="button" class="danger-btn js-remove-feature" data-id="@feature.Id">
                                        <i class="bx bx-trash"></i>
                                        حذف
                                    </button>
                                </td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>
        </section>

        <div class="footer-actions">
            <div class="right-actions">
                <button type="button" class="btn soft-btn" id="btnPreviousTab">
                    <i class="bx bx-right-arrow-alt me-1"></i>
                    مرحله قبل
                </button>
                <button type="button" class="btn btn-primary" id="btnNextTab">
                    مرحله بعد
                    <i class="bx bx-left-arrow-alt ms-1"></i>
                </button>
            </div>
            <div class="left-actions">
                <a asp-area="Admin" asp-controller="Products" asp-action="Index" class="btn soft-btn">انصراف</a>
                <button type="button" class="btn btn-success" id="btnFinish">
                    <i class="bx bx-check me-1"></i>
                    ذخیره نهایی
                </button>
            </div>
        </div>
    </div>
</div>

<div id="createColorModal" class="product-modal" aria-hidden="true">
    <div class="product-modal-dialog">
        <div class="product-modal-header">
            <h3 class="product-modal-title">افزودن رنگ جدید به لیست</h3>
            <button type="button" class="product-modal-close" data-modal-close aria-label="بستن">×</button>
        </div>
        <div class="product-modal-body">
            <div id="createColorError" class="product-alert error"></div>
            <div class="mb-3">
                <label class="form-label" for="newColorName">نام رنگ</label>
                <input id="newColorName" class="form-control" placeholder="مثلا: سبز زیتونی" />
            </div>
            <div>
                <label class="form-label" for="newColorCode">کد رنگ</label>
                <input id="newColorCode" type="color" class="form-control form-control-color" value="#64748b" />
            </div>
        </div>
        <div class="product-modal-footer">
            <button type="button" class="btn soft-btn" data-modal-close>انصراف</button>
            <button type="button" id="btnSaveNewColor" class="btn btn-primary">
                <i class="bx bx-save me-1"></i>
                افزودن رنگ
            </button>
        </div>
    </div>
</div>

@section Scripts {
    @{
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }
    <script src="https://cdn.ckeditor.com/4.22.1/standard/ckeditor.js"></script>
    <script>
        window.productEditorConfig = @Html.Raw(editorConfigJson);
    </script>
    <script src="~/Admin/js/product-editor.js" asp-append-version="true"></script>
}

2 . کدهای ProductsController

 public class ProductsController : AdminBaseController
 {
     private readonly ICategoryService _categoryService;
     private readonly IProductService _productService;

     public ProductsController(
         ICategoryService categoryService,
         IProductService productService)
     {
         _categoryService = categoryService;
         _productService = productService;
     }

     [HttpGet]
     public async Task<IActionResult> Index()
     {
         var model = await _productService.GetProductsAsync();
         return View(model);
     }

     [HttpGet]
     public async Task<IActionResult> CreateEdit(int? id, string? tab)
     {
         AdminCreateProductViewModel model;

         if (id.HasValue)
         {
             model = await _productService.GetForEditAsync(id.Value);

             if (model == null)
             {
                 return NotFound();
             }
         }
         else
         {
             model = new AdminCreateProductViewModel();
         }

         model.CategoryTree = await _categoryService.GetTreeAsync();
         model.AllColors = await _productService.GetColorsAsync();
         model.Tags = JsonSerializer.Serialize(model.SelectedTags.Select(x => new { value = x }));
         ViewData["ActiveTab"] = tab is "tab-colors" or "tab-gallery" or "tab-features"
             ? tab
             : "tab-basic";

         return View("CreateEdit", model);
     }

     [HttpGet]
     public async Task<IActionResult> GetTags()
     {
         var tags = await _productService.GetTags();

         return Json(tags.Select(x => new
         {
             id = x.TagId,
             value = x.Title
         }));
     }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public async Task<IActionResult> CreateEdit(AdminCreateProductViewModel model, bool next = false)
     {
         if (!ModelState.IsValid)
         {
             await PopulateProductEditorAsync(model);
             return View("CreateEdit", model);
         }

         var result = await _productService.SaveBasicInfoAsync(new SaveProductBasicInfoViewModel
         {
             ProductId = model.ProductId,
             Title = model.Title,
             Price = model.Price,
             CategoryId = model.CategoryId,
             Count = model.Count,
             ShortDiscription = model.ShortDiscription,
             Review = model.Review,
             DetailReview = model.DetailReview,
             Tags = model.Tags,
             IsActive = model.IsActive,
             ImageFile = model.ImageFile
         });

         if (!result.IsSuccess)
         {
             ModelState.AddModelError(string.Empty, result.Message);
             await PopulateProductEditorAsync(model);
             return View("CreateEdit", model);
         }

         return RedirectToAction(nameof(CreateEdit), new
         {
             id = result.ProductId,
             tab = next ? "tab-colors" : "tab-basic"
         });
     }

     [HttpGet]
     public async Task<IActionResult> GetColors()
     {
         return Json(await _productService.GetColorsAsync());
     }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public async Task<IActionResult> SaveBasicInfo([FromForm] SaveProductBasicInfoViewModel model)
     {
         if (!ModelState.IsValid)
             return InvalidModelState();

         return Json(await _productService.SaveBasicInfoAsync(model));
     }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public async Task<IActionResult> CreateColor([FromForm] CreateColorViewModel model)
     {
         if (!ModelState.IsValid)
             return InvalidModelState();

         return Json(await _productService.CreateColorAsync(model));
     }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public async Task<IActionResult> AddColor([FromForm] AddProductColorViewModel model)
     {
         if (!ModelState.IsValid)
             return InvalidModelState();

         return Json(await _productService.AddColorAsync(model));
     }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public async Task<IActionResult> RemoveColor(int productId, int id)
     {
         return Json(await _productService.RemoveColorAsync(productId, id));
     }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public async Task<IActionResult> AddGallery([FromForm] AddProductGalleryViewModel model)
     {
         if (!ModelState.IsValid)
             return InvalidModelState();

         return Json(await _productService.AddGalleryAsync(model));
     }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public async Task<IActionResult> RemoveGallery(int productId, int id)
     {
         return Json(await _productService.RemoveGalleryAsync(productId, id));
     }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public async Task<IActionResult> AddFeature([FromForm] AddProductFeatureViewModel model)
     {
         if (!ModelState.IsValid)
             return InvalidModelState();

         return Json(await _productService.AddFeatureAsync(model));
     }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public async Task<IActionResult> RemoveFeature(int productId, int id)
     {
         return Json(await _productService.RemoveFeatureAsync(productId, id));
     }

     [HttpPost]
     [ValidateAntiForgeryToken]
     public async Task<IActionResult> CreateTag([FromForm] CreateTagViewModel model)
     {
         if (!ModelState.IsValid)
             return InvalidModelState();

         return Json(await _productService.CreateTagAsync(model));
     }

     private BadRequestObjectResult InvalidModelState()
     {
         var message = string.Join(" ", ModelState.Values
             .SelectMany(x => x.Errors)
             .Select(x => x.ErrorMessage)
             .Where(x => !string.IsNullOrWhiteSpace(x)));

         return BadRequest(new
         {
             isSuccess = false,
             message = string.IsNullOrWhiteSpace(message)
                 ? "اطلاعات ارسال‌شده معتبر نیست."
                 : message
         });
     }

     private async Task PopulateProductEditorAsync(AdminCreateProductViewModel model)
     {
         model.CategoryTree = await _categoryService.GetTreeAsync();
         model.AllColors = await _productService.GetColorsAsync();
     }
 }

3. کدهای ProductService

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;

    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }

    public async Task<IEnumerable<ProductTagEntity>> GetTags() =>
        await _productRepository.GetTags();

    public async Task<List<ColorListItemViewModel>> GetColorsAsync()
    {
        var colors = await _productRepository.GetColorsAsync();
        return colors.Select(MapColor).ToList();
    }

    public async Task<AdminCreateProductViewModel?> GetForEditAsync(int id)
    {
        var product = await _productRepository.GetProductForEditAsync(id);
        if (product == null)
            return null;

        return new AdminCreateProductViewModel
        {
            ProductId = product.Id,
            CategoryId = product.CategoryId,
            Title = product.Title,
            Price = product.Price,
            ShortDiscription = product.ShortDiscription,
            Review = product.Review,
            DetailReview = product.DetailReview,
            ImageName = product.ImageName,
            IsActive = product.IsActive,
            Count = product.Count,
            SelectedTags = product.ProductTags?
                .Select(x => x.TagTitle)
                .Where(x => !string.IsNullOrWhiteSpace(x))
                .Distinct(StringComparer.OrdinalIgnoreCase)
                .ToList() ?? new(),
            Colors = product.ProductColors?
                .Where(x => !x.IsDeleted)
                .Select(MapProductColor)
                .ToList() ?? new(),
            Gallery = product.ProductGalleries?
                .Where(x => !x.IsDeleted)
                .Select(MapGallery)
                .ToList() ?? new(),
            Features = product.ProductFeatures?
                .Where(x => !x.IsDeleted)
                .Select(MapFeature)
                .ToList() ?? new()
        };
    }

    public async Task<List<ProductListItemViewModel>> GetProductsAsync()
    {
        var products = await _productRepository.GetProductsAsync();
        return products.Select(x => new ProductListItemViewModel
        {
            ProductId = x.Id,
            Title = x.Title,
            ImageName = x.ImageName,
            Price = x.Price,
            Count = x.Count,
            IsActive = x.IsActive
        }).ToList();
    }

    public async Task<ProductSaveResultViewModel> SaveBasicInfoAsync(SaveProductBasicInfoViewModel model)
    {
        if (string.IsNullOrWhiteSpace(model.Title))
            return ProductFailure("عنوان محصول الزامی است.");

        if (model.CategoryId <= 0)
            return ProductFailure("انتخاب دسته‌بندی الزامی است.");

        if (model.Price <= 0)
            return ProductFailure("قیمت باید بزرگ‌تر از صفر باشد.");

        try
        {
            Product? product = null;
            if (model.ProductId.HasValue && model.ProductId.Value > 0)
                product = await _productRepository.GetProductForEditAsync(model.ProductId.Value);

            var isNew = product == null;
            product ??= new Product
            {
                CreateDate = DateTime.Now,
                ProductColors = new List<ProductColor>(),
                ProductFeatures = new List<ProductFeature>(),
                ProductGalleries = new List<ProductGallery>(),
                ProductTags = new List<ProductTag>()
            };

            product.CategoryId = model.CategoryId;
            product.Title = model.Title.Trim();
            product.Price = model.Price;
            product.ShortDiscription = model.ShortDiscription;
            product.Review = model.Review;
            product.DetailReview = model.DetailReview;
            product.IsActive = model.IsActive;
            product.Count = Math.Max(0, model.Count);
            product.UpdateDate = isNew ? null : DateTime.Now;

            if (model.ImageFile != null)
            {
                if (!model.ImageFile.ImageValidate())
                    return ProductFailure("فرمت تصویر محصول معتبر نیست.");

                if (!string.IsNullOrWhiteSpace(product.ImageName))
                    FileUploader.DeleteFile(product.ImageName, "Products");

                product.ImageName = await FileUploader.SaveFileAsync(model.ImageFile, "Products");
            }

            if (isNew)
                _productRepository.Add(product);
            else
                _productRepository.Update(product);

            await _productRepository.SaveAsync();
            await ReplaceProductTagsAsync(product, model.Tags);
            await _productRepository.SaveAsync();

            return new ProductSaveResultViewModel
            {
                IsSuccess = true,
                ProductId = product.Id,
                Message = isNew ? "اطلاعات پایه محصول ذخیره شد." : "تغییرات محصول ذخیره شد."
            };
        }
        catch
        {
            return ProductFailure("هنگام ذخیره محصول خطایی رخ داد.");
        }
    }

    public async Task<EntityMutationResult<ColorListItemViewModel>> CreateColorAsync(CreateColorViewModel model)
    {
        var name = model.Name?.Trim();
        var code = NormalizeColorCode(model.Code);

        if (string.IsNullOrWhiteSpace(name))
            return MutationFailure<ColorListItemViewModel>("نام رنگ الزامی است.");

        if (code == null)
            return MutationFailure<ColorListItemViewModel>("کد رنگ معتبر نیست.");

        if (await _productRepository.ColorExistsAsync(name))
            return MutationFailure<ColorListItemViewModel>("این رنگ قبلا در لیست ثبت شده است.");

        var color = new Color
        {
            Name = name,
            Code = code,
            CreateDate = DateTime.Now
        };

        _productRepository.AddColor(color);
        await _productRepository.SaveAsync();

        return new EntityMutationResult<ColorListItemViewModel>
        {
            IsSuccess = true,
            Message = "رنگ جدید به لیست اضافه شد.",
            Data = MapColor(color)
        };
    }

    public async Task<EntityMutationResult<ProductColorViewModel>> AddColorAsync(AddProductColorViewModel model)
    {
        var product = await _productRepository.GetProductForEditAsync(model.ProductId);
        var color = await _productRepository.GetColorAsync(model.ColorId);

        if (product == null)
            return MutationFailure<ProductColorViewModel>("محصول پیدا نشد.");

        if (color == null)
            return MutationFailure<ProductColorViewModel>("رنگ انتخاب‌شده پیدا نشد.");

        if (product.ProductColors?.Any(x =>
            !x.IsDeleted && string.Equals(x.Name, color.Name, StringComparison.OrdinalIgnoreCase)) == true)
        {
            return MutationFailure<ProductColorViewModel>("این رنگ قبلا برای محصول انتخاب شده است.");
        }

        if (model.IsDefault)
        {
            foreach (var item in product.ProductColors ?? Enumerable.Empty<ProductColor>())
                item.IsDefault = false;
        }

        var productColor = new ProductColor
        {
            ProductId = model.ProductId,
            Name = color.Name,
            Code = color.Code,
            Price = Math.Max(0, model.Price),
            Quantity = Math.Max(0, model.Quantity),
            IsDefault = model.IsDefault,
            CreateDate = DateTime.Now
        };

        _productRepository.AddProductColor(productColor);
        await _productRepository.SaveAsync();

        var result = MapProductColor(productColor);
        result.ColorId = color.Id;

        return new EntityMutationResult<ProductColorViewModel>
        {
            IsSuccess = true,
            Message = "رنگ به محصول اضافه شد.",
            Data = result
        };
    }

    public async Task<OperationResult> RemoveColorAsync(int productId, int id)
    {
        var color = await _productRepository.GetProductColorAsync(productId, id);
        if (color == null)
            return Failure("رنگ محصول پیدا نشد.");

        color.IsDeleted = true;
        color.DeleteDate = DateTime.Now;
        color.UpdateDate = DateTime.Now;
        _productRepository.UpdateProductColor(color);
        await _productRepository.SaveAsync();
        return Success("رنگ از محصول حذف شد.");
    }

    public async Task<EntityMutationResult<ProductGalleryViewModel>> AddGalleryAsync(AddProductGalleryViewModel model)
    {
        var product = await _productRepository.GetProductForEditAsync(model.ProductId);
        if (product == null)
            return MutationFailure<ProductGalleryViewModel>("محصول پیدا نشد.");

        if (model.Image == null || model.Image.Length == 0)
            return MutationFailure<ProductGalleryViewModel>("انتخاب تصویر الزامی است.");

        if (!model.Image.ImageValidate())
            return MutationFailure<ProductGalleryViewModel>("فرمت تصویر گالری معتبر نیست.");

        var gallery = new ProductGallery
        {
            ProductId = model.ProductId,
            Alt = string.IsNullOrWhiteSpace(model.Alt) ? product.Title : model.Alt.Trim(),
            ImageName = await FileUploader.SaveFileAsync(model.Image, "Products"),
            CreateDate = DateTime.Now
        };

        _productRepository.AddProductGallery(gallery);
        await _productRepository.SaveAsync();

        return new EntityMutationResult<ProductGalleryViewModel>
        {
            IsSuccess = true,
            Message = "تصویر به گالری اضافه شد.",
            Data = MapGallery(gallery)
        };
    }

    public async Task<OperationResult> RemoveGalleryAsync(int productId, int id)
    {
        var gallery = await _productRepository.GetProductGalleryAsync(productId, id);
        if (gallery == null)
            return Failure("تصویر گالری پیدا نشد.");

        FileUploader.DeleteFile(gallery.ImageName, "Products");
        gallery.IsDeleted = true;
        gallery.DeleteDate = DateTime.Now;
        gallery.UpdateDate = DateTime.Now;
        _productRepository.UpdateProductGallery(gallery);
        await _productRepository.SaveAsync();
        return Success("تصویر از گالری حذف شد.");
    }

    public async Task<EntityMutationResult<ProductFeatureViewModel>> AddFeatureAsync(AddProductFeatureViewModel model)
    {
        var product = await _productRepository.GetProductForEditAsync(model.ProductId);
        if (product == null)
            return MutationFailure<ProductFeatureViewModel>("محصول پیدا نشد.");

        if (string.IsNullOrWhiteSpace(model.Name) || string.IsNullOrWhiteSpace(model.Value))
            return MutationFailure<ProductFeatureViewModel>("نام و مقدار ویژگی را وارد کنید.");

        var feature = new ProductFeature
        {
            ProductId = model.ProductId,
            Name = model.Name.Trim(),
            Value = model.Value.Trim(),
            CreateDate = DateTime.Now
        };

        _productRepository.AddProductFeature(feature);
        await _productRepository.SaveAsync();

        return new EntityMutationResult<ProductFeatureViewModel>
        {
            IsSuccess = true,
            Message = "ویژگی محصول اضافه شد.",
            Data = MapFeature(feature)
        };
    }

    public async Task<OperationResult> RemoveFeatureAsync(int productId, int id)
    {
        var feature = await _productRepository.GetProductFeatureAsync(productId, id);
        if (feature == null)
            return Failure("ویژگی محصول پیدا نشد.");

        feature.IsDeleted = true;
        feature.DeleteDate = DateTime.Now;
        feature.UpdateDate = DateTime.Now;
        _productRepository.UpdateProductFeature(feature);
        await _productRepository.SaveAsync();
        return Success("ویژگی از محصول حذف شد.");
    }

    public async Task<EntityMutationResult<object>> CreateTagAsync(CreateTagViewModel model)
    {
        var title = model.Title?.Trim();
        if (string.IsNullOrWhiteSpace(title))
            return MutationFailure<object>("عنوان برچسب الزامی است.");

        var existing = (await _productRepository.GetTags())
            .FirstOrDefault(x => string.Equals(x.Title, title, StringComparison.OrdinalIgnoreCase));

        if (existing != null)
        {
            return new EntityMutationResult<object>
            {
                IsSuccess = true,
                Message = "این برچسب قبلا در لیست وجود دارد.",
                Data = new { id = existing.TagId, value = existing.Title }
            };
        }

        var tag = new ProductTagEntity
        {
            Title = title,
            Slug = AddSlug.GenerateSlug(title)
        };

        _productRepository.AddTag(tag);
        await _productRepository.SaveAsync();

        return new EntityMutationResult<object>
        {
            IsSuccess = true,
            Message = "برچسب جدید به لیست اضافه شد.",
            Data = new { id = tag.TagId, value = tag.Title }
        };
    }

    private async Task ReplaceProductTagsAsync(Product product, string? rawTags)
    {
        foreach (var oldTag in product.ProductTags?.ToList() ?? new List<ProductTag>())
            _productRepository.RemoveProductTag(oldTag);

        var tagTitles = ParseTags(rawTags).ToList();
        if (tagTitles.Count == 0)
            return;

        var tags = (await _productRepository.GetTags()).ToList();
        var newTags = tagTitles
            .Where(title => !tags.Any(tag =>
                string.Equals(tag.Title, title, StringComparison.OrdinalIgnoreCase)))
            .Select(title => new ProductTagEntity
            {
                Title = title,
                Slug = AddSlug.GenerateSlug(title)
            })
            .ToList();

        foreach (var newTag in newTags)
            _productRepository.AddTag(newTag);

        tags.AddRange(newTags);

        foreach (var title in tagTitles)
        {
            var existing = tags.FirstOrDefault(x =>
                string.Equals(x.Title, title, StringComparison.OrdinalIgnoreCase));

            _productRepository.AddProductTag(new ProductTag
            {
                ProductId = product.Id,
                TagTitle = existing!.Title
            });
        }
    }
}

4. کدهای ProductRepository

 

 public class ProductRepository : IProductRepository
 {
     public ProductRepository(AppDbContext context) : base(context)
     {
     }

     public async Task<IEnumerable<Tag>> GetTags()
     {
         return await _context.Tags
             .AsNoTracking()
             .OrderBy(x => x.Title)
             .ToListAsync();
     }

     public async Task<List<Color>> GetColorsAsync()
     {
         return await _context.Colors
             .AsNoTracking()
             .Where(x => !x.IsDeleted)
             .OrderBy(x => x.Name)
             .ToListAsync();
     }

     public async Task<Product?> GetProductForEditAsync(int id)
     {
         return await _context.Products
             .Include(x => x.ProductColors!)
             .Include(x => x.ProductFeatures)
             .Include(x => x.ProductGalleries)
             .Include(x => x.ProductTags)
             .SingleOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
     }

     public async Task<List<Product>> GetProductsAsync()
     {
         return await _context.Products
             .AsNoTracking()
             .Where(x => !x.IsDeleted)
             .OrderByDescending(x => x.Id)
             .ToListAsync();
     }

     public async Task<Color?> GetColorAsync(int id)
     {
         return await _context.Colors
             .SingleOrDefaultAsync(x => x.Id == id && !x.IsDeleted);
     }

     public async Task<Tag?> GetTagAsync(int id)
     {
         return await _context.Tags.SingleOrDefaultAsync(x => x.TagId == id);
     }

     public async Task<bool> ColorExistsAsync(string name)
     {
         return await _context.Colors.AnyAsync(x => !x.IsDeleted && x.Name == name);
     }

     public async Task<bool> TagExistsAsync(string title)
     {
         return await _context.Tags.AnyAsync(x => x.Title == title);
     }

     public async Task<bool> ProductColorExistsAsync(int productId, int colorId)
     {
         var color = await GetColorAsync(colorId);

         if (color == null)
         {
             return false;
         }

         return await _context.ProductColors
             .AnyAsync(x => x.ProductId == productId && x.Name == color.Name && !x.IsDeleted);
     }

     public async Task<ProductColor?> GetProductColorAsync(int productId, int id)
     {
         return await _context.ProductColors
             .SingleOrDefaultAsync(x => x.ProductId == productId && x.Id == id && !x.IsDeleted);
     }

     public async Task<ProductGallery?> GetProductGalleryAsync(int productId, int id)
     {
         return await _context.ProductGalleries
             .SingleOrDefaultAsync(x => x.ProductId == productId && x.Id == id && !x.IsDeleted);
     }

     public async Task<ProductFeature?> GetProductFeatureAsync(int productId, int id)
     {
         return await _context.ProductFeatures
             .SingleOrDefaultAsync(x => x.ProductId == productId && x.Id == id && !x.IsDeleted);
     }

     public void AddColor(Color color) => _context.Colors.Add(color);
     public void AddTag(Tag tag) => _context.Tags.Add(tag);
     public void AddProductColor(ProductColor color) => _context.ProductColors.Add(color);
     public void AddProductGallery(ProductGallery gallery) => _context.ProductGalleries.Add(gallery);
     public void AddProductFeature(ProductFeature feature) => _context.ProductFeatures.Add(feature);
     public void AddProductTag(ProductTag tag) => _context.ProductTags.Add(tag);
     public void UpdateProductColor(ProductColor color) => _context.ProductColors.Update(color);
     public void UpdateProductGallery(ProductGallery gallery) => _context.ProductGalleries.Update(gallery);
     public void UpdateProductFeature(ProductFeature feature) => _context.ProductFeatures.Update(feature);
     public void RemoveProductColor(ProductColor color) => _context.ProductColors.Remove(color);
     public void RemoveProductGallery(ProductGallery gallery) => _context.ProductGalleries.Remove(gallery);
     public void RemoveProductFeature(ProductFeature feature) => _context.ProductFeatures.Remove(feature);
     public void RemoveProductTag(ProductTag tag) => _context.ProductTags.Remove(tag);
 }

 

 

 

  • 1405/07/19
  • ساعت 19:23

سلام 

بسیار عالی 

ممنون از شما