From 3ce43df93ce2c642b46a4e571b14803d7ad13e61 Mon Sep 17 00:00:00 2001 From: Warren Chen Date: Thu, 7 May 2026 00:37:33 +0900 Subject: [PATCH] Add product hero section and homepage hero products component - Introduced a new view component for product hardware hero section (_ProductHardwarePrHero.cshtml) that displays product details including titles, features, and a copy with expandable text. - Created a homepage hero products component (Default.cshtml) to showcase the first product in a list and provide navigation dots for additional products. - Added a placeholder for when no products are available, guiding users to set products in the backend. - Included a new image asset (vehicle-transportation.png) for use in the product hero section. --- docs/category-restructure-memo.md | 14 + docs/change-log.md | 142 +++++ docs/frontend-media-storage-plan.md | 474 +++++++++++++++ docs/operations-memo.md | 48 ++ .../Domain/Catalog/HomepageHeroProduct.cs | 14 + .../Domain/Catalog/NopProductPrDefaults.cs | 25 + .../Catalog/HomepageHeroProductBuilder.cs | 28 + .../HomepageHeroProductMigration.cs | 16 + .../UpgradeTo470/SchemaMigration.cs | 5 +- .../Catalog/HomepageHeroProductService.cs | 49 ++ .../Catalog/IHomepageHeroProductService.cs | 14 + .../Nop.Services/Catalog/ProductService.cs | 12 +- .../Infrastructure/NopStartup.cs | 3 +- .../Nop.Web/App_Data/appsettings.json | 2 +- .../Admin/Controllers/ProductController.cs | 157 ++++- .../Admin/Factories/ProductModelFactory.cs | 27 +- .../Catalog/HomepageHeroProductModel.cs | 23 + .../Admin/Models/Catalog/ProductModel.cs | 37 +- .../Admin/Views/Product/HomepageHero.cshtml | 79 +++ .../Areas/Admin/Views/Product/List.cshtml | 6 +- .../Product/_CreateOrUpdate.HardwarePr.cshtml | 101 ++++ .../Views/Product/_CreateOrUpdate.cshtml | 3 +- .../HomepageHeroProductsViewComponent.cs | 92 +++ .../Nop.Web/Factories/ProductModelFactory.cs | 36 +- .../Models/Catalog/ProductDetailsModel.cs | 15 +- .../Models/Catalog/ProductOverviewModel.cs | 5 +- .../DefaultClean/Kneo/Content/css/home.css | 555 ++++++++++++++++++ .../images/common/hw-product-back-hover.png | Bin 0 -> 279972 bytes .../Content/images/common/hw-product-back.png | Bin 0 -> 7601 bytes .../Content/images/common/in-stock-hover.png | Bin 0 -> 1779 bytes .../Kneo/Content/images/common/in-stock.png | Bin 0 -> 1926 bytes .../Content/images/common/low-stock-hover.png | Bin 0 -> 2551 bytes .../Kneo/Content/images/common/low-stock.png | Bin 0 -> 3231 bytes .../Content/images/common/out-stock-hover.png | Bin 0 -> 1704 bytes .../Kneo/Content/images/common/out-stock.png | Bin 0 -> 1754 bytes .../Content/images/hero/hero-background.png | Bin 0 -> 1855276 bytes .../Content/images/hero/hero-effecient.png | Bin 0 -> 317252 bytes .../Kneo/Content/images/hero/see-more.png | Bin 0 -> 2358 bytes .../Content/images/home/app-back-band.png | Bin 0 -> 1692066 bytes .../Content/images/home/book-back-band.png | Bin 0 -> 35165 bytes .../Content/images/home/future-city-hover.png | Bin 0 -> 135455 bytes .../Kneo/Content/images/home/future-city.png | Bin 0 -> 7345 bytes .../Kneo/Content/images/home/hw-back-band.png | Bin 0 -> 1804094 bytes .../images/home/medical-health-hover.png | Bin 0 -> 138199 bytes .../Content/images/home/medical-health.png | Bin 0 -> 8413 bytes .../Content/images/home/smart-home-hover.png | Bin 0 -> 132453 bytes .../Kneo/Content/images/home/smart-home.png | Bin 0 -> 7368 bytes .../images/home/smart-manufacture-hover.png | Bin 0 -> 135758 bytes .../Content/images/home/smart-manufacture.png | Bin 0 -> 7376 bytes .../images/home/smart-retail-hover.png | Bin 0 -> 137262 bytes .../Kneo/Content/images/home/smart-retail.png | Bin 0 -> 7565 bytes .../home/vehicle-transportation-hover.png | Bin 0 -> 136556 bytes .../images/home/vehicle-transportation.png | Bin 0 -> 8006 bytes .../DefaultClean/Views/Shared/Head.cshtml | 3 +- .../Nop.Web/Views/Home/Index.cshtml | 127 +++- .../Product/ProductTemplate.Hardware.cshtml | 3 +- .../Product/_ProductHardwarePrHero.cshtml | 59 ++ .../HomepageHeroProducts/Default.cshtml | 28 + .../HomepageProducts/Default.cshtml | 41 +- 59 files changed, 2202 insertions(+), 41 deletions(-) create mode 100644 docs/category-restructure-memo.md create mode 100644 docs/change-log.md create mode 100644 docs/frontend-media-storage-plan.md create mode 100644 docs/operations-memo.md create mode 100644 src/Libraries/Nop.Core/Domain/Catalog/HomepageHeroProduct.cs create mode 100644 src/Libraries/Nop.Core/Domain/Catalog/NopProductPrDefaults.cs create mode 100644 src/Libraries/Nop.Data/Mapping/Builders/Catalog/HomepageHeroProductBuilder.cs create mode 100644 src/Libraries/Nop.Data/Migrations/UpgradeTo470/HomepageHeroProductMigration.cs create mode 100644 src/Libraries/Nop.Services/Catalog/HomepageHeroProductService.cs create mode 100644 src/Libraries/Nop.Services/Catalog/IHomepageHeroProductService.cs create mode 100644 src/Presentation/Nop.Web/Areas/Admin/Models/Catalog/HomepageHeroProductModel.cs create mode 100644 src/Presentation/Nop.Web/Areas/Admin/Views/Product/HomepageHero.cshtml create mode 100644 src/Presentation/Nop.Web/Areas/Admin/Views/Product/_CreateOrUpdate.HardwarePr.cshtml create mode 100644 src/Presentation/Nop.Web/Components/HomepageHeroProductsViewComponent.cs create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/css/home.css create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/common/hw-product-back-hover.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/common/hw-product-back.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/common/in-stock-hover.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/common/in-stock.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/common/low-stock-hover.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/common/low-stock.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/common/out-stock-hover.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/common/out-stock.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/hero/hero-background.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/hero/hero-effecient.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/hero/see-more.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/app-back-band.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/book-back-band.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/future-city-hover.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/future-city.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/hw-back-band.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/medical-health-hover.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/medical-health.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/smart-home-hover.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/smart-home.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/smart-manufacture-hover.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/smart-manufacture.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/smart-retail-hover.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/smart-retail.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/vehicle-transportation-hover.png create mode 100644 src/Presentation/Nop.Web/Themes/DefaultClean/Kneo/Content/images/home/vehicle-transportation.png create mode 100644 src/Presentation/Nop.Web/Views/Product/_ProductHardwarePrHero.cshtml create mode 100644 src/Presentation/Nop.Web/Views/Shared/Components/HomepageHeroProducts/Default.cshtml diff --git a/docs/category-restructure-memo.md b/docs/category-restructure-memo.md new file mode 100644 index 0000000..8e259f3 --- /dev/null +++ b/docs/category-restructure-memo.md @@ -0,0 +1,14 @@ +# 分類調整備忘(已整併) + +此文件已整併到通用文件,避免後續骨架調整分散在多份 memo: + +1. 操作步驟請看: +[operations-memo.md](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/operations-memo.md) + +2. 已完成變更請看: +[change-log.md](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/change-log.md) + +3. 分類相關 SQL: +- [category-restructure-20260503.sql](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/sql/category-restructure-20260503.sql) +- [category-delete-legacy-software-children-20260503.sql](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/sql/category-delete-legacy-software-children-20260503.sql) +- [category-localization-backfill-tw-20260503.sql](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/sql/category-localization-backfill-tw-20260503.sql) diff --git a/docs/change-log.md b/docs/change-log.md new file mode 100644 index 0000000..af53407 --- /dev/null +++ b/docs/change-log.md @@ -0,0 +1,142 @@ +# 變更紀錄(通用) + +## 用途 + +本文件用於記錄「已實作的工程變更」,包含程式、SQL、設定與驗證結果。 +每次調整請以日期區段追加,避免只留在對話訊息中。 + +--- + +## 2026-05-03:前台/媒體改造規劃文件建立 + +### 內容 + +1. 新增前台翻新與媒體儲存架構規劃文件: +[frontend-media-storage-plan.md](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/frontend-media-storage-plan.md) + +2. 規劃重點已明確寫入: +- 新 Theme(`Kneo`)方向 +- 私有 S3/MinIO + media proxy +- 分階段導入(前台與媒體基礎、PictureService 套用、最終遷移) +- 部署檢查清單與風險控制 + +--- + +## 2026-05-03:本機啟動連接埠修正 + +### 內容 + +1. 修正啟動設定避免自動開瀏覽器連到錯誤埠: +[launchSettings.json](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Properties/launchSettings.json) + +2. 調整結果: +- `applicationUrl` 由 `https://localhost:5001;http://localhost:5000` +- 改為 `http://localhost:5000` + +--- + +## 2026-05-03 ~ 2026-05-05:分類結構調整 SQL 與資料落地 + +### 新增檔案 + +1. [category-restructure-20260503.sql](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/sql/category-restructure-20260503.sql) +2. [category-delete-legacy-software-children-20260503.sql](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/sql/category-delete-legacy-software-children-20260503.sql) +3. [category-localization-backfill-tw-20260503.sql](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/sql/category-localization-backfill-tw-20260503.sql) + +### 重點變更 + +1. 新分類骨架: +- `Hardware` +- `Software` +- `AI Application`:`Smart Retail`、`Medical & Health`、`Vehicle & Transportation`、`Smart Manufacture`、`Smart Home`、`Future City` +- `Books`:`Physical Books`、`E-Books`、`Certification Books` + +2. 中文多語系: +- 透過 `LocalizedProperty` 寫入 +- 修正語系代碼匹配包含 `tw` + +3. 舊子類刪除策略: +- 由名稱比對改為父節點比對 +- 只要是 `Software` 的直屬子類即納入清理 + +4. 實表名相容: +- SQL 依實際資料表 `Product_Category_Mapping` 修正(非 `ProductCategory`) + +### 已驗證結果(kneo_dev) + +1. 分類重構 SQL 已成功 `COMMIT`。 +2. 舊 `Software` 子類已清空(查詢為 0 筆)。 +3. `AI Application` 六子類仍存在。 +4. 13 個目標分類中文多語系已補齊。 + +--- + +## 2026-05-05:硬體 PR 欄位實作(商品層) + +### 內容 + +1. 新增產品 PR 欄位預設鍵定義(以 `GenericAttribute` 儲存): +[NopProductPrDefaults.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Libraries/Nop.Core/Domain/Catalog/NopProductPrDefaults.cs) + +2. 後台產品模型新增 PR 欄位(含多語系與字級欄位): +[ProductModel.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Areas/Admin/Models/Catalog/ProductModel.cs) + +3. 後台產品編輯頁新增「硬體 PR 區塊」卡片與欄位: +- [_CreateOrUpdate.cshtml](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Areas/Admin/Views/Product/_CreateOrUpdate.cshtml) +- [_CreateOrUpdate.HardwarePr.cshtml](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Areas/Admin/Views/Product/_CreateOrUpdate.HardwarePr.cshtml) + +4. 後台儲存流程已接上: +- `Create/Edit` 會寫入 PR 欄位 +- `UpdateLocalesAsync` 會寫入各語言版本欄位(key 帶 `LanguageId`) +[ProductController.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Areas/Admin/Controllers/ProductController.cs) + +5. 後台載入流程已接上(含多語系讀取): +[ProductModelFactory.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Areas/Admin/Factories/ProductModelFactory.cs) + +6. 前台硬體商品頁已接上 PR 區塊渲染: +- 公開模型新增對應欄位: +[ProductDetailsModel.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Models/Catalog/ProductDetailsModel.cs) +- 產品模型工廠新增語言 fallback 讀取: +[ProductModelFactory.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Factories/ProductModelFactory.cs) +- 硬體模板插入 PR 區塊: +[ProductTemplate.Hardware.cshtml](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Views/Product/ProductTemplate.Hardware.cshtml) +- PR 區塊 partial(含 PR 全文展開 `details/summary`): +[_ProductHardwarePrHero.cshtml](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Views/Product/_ProductHardwarePrHero.cshtml) + +### 驗證 + +1. `dotnet build src/Presentation/Nop.Web/Nop.Web.csproj -c Debug` 成功(0 error)。 + +--- + +## 2026-05-05:首頁輪播開關與排序(獨立管理) + +### 內容 + +1. 新增首頁輪播映射實體: +[HomepageHeroProduct.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Libraries/Nop.Core/Domain/Catalog/HomepageHeroProduct.cs) + +2. 新增資料表 mapping builder: +[HomepageHeroProductBuilder.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Libraries/Nop.Data/Mapping/Builders/Catalog/HomepageHeroProductBuilder.cs) + +3. 新增 migration(供既有環境升級時建表): +[HomepageHeroProductMigration.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Libraries/Nop.Data/Migrations/UpgradeTo470/HomepageHeroProductMigration.cs) + +4. 新增服務層: +- [IHomepageHeroProductService.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Libraries/Nop.Services/Catalog/IHomepageHeroProductService.cs) +- [HomepageHeroProductService.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Libraries/Nop.Services/Catalog/HomepageHeroProductService.cs) + +5. 後台商品管理新增首頁輪播管理頁(加入/刪除/啟用/排序): +- [ProductController.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Areas/Admin/Controllers/ProductController.cs) +- [HomepageHero.cshtml](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Areas/Admin/Views/Product/HomepageHero.cshtml) +- [HomepageHeroProductModel.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Areas/Admin/Models/Catalog/HomepageHeroProductModel.cs) +- [List.cshtml](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Areas/Admin/Views/Product/List.cshtml)(增加「首頁輪播」入口按鈕) + +6. 前台首頁接入輪播資料來源: +- [HomepageHeroProductsViewComponent.cs](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Components/HomepageHeroProductsViewComponent.cs) +- [Default.cshtml](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Views/Shared/Components/HomepageHeroProducts/Default.cshtml) +- [Index.cshtml](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/src/Presentation/Nop.Web/Views/Home/Index.cshtml) + +### 驗證 + +1. `dotnet build src/Presentation/Nop.Web/Nop.Web.csproj -c Debug` 成功(0 error)。 diff --git a/docs/frontend-media-storage-plan.md b/docs/frontend-media-storage-plan.md new file mode 100644 index 0000000..93ccf38 --- /dev/null +++ b/docs/frontend-media-storage-plan.md @@ -0,0 +1,474 @@ +# Frontend Refresh and Media Storage Plan + +## Purpose + +This plan defines the first major rebuild track for the storefront and media layer. The goal is to replace the current storefront theme while introducing a private S3-compatible media storage layer that can later take over nopCommerce product pictures and support a controlled production migration. + +The current data volume is small, so the plan favors clear boundaries and reversible migration steps over heavy batch infrastructure. + +## Fixed Decisions + +- The existing storefront theme can be abandoned for the public frontend rebuild. +- A new theme will be created instead of modifying `DefaultClean` in place. +- The first frontend implementation is desktop-first. Responsive behavior can be added later, but HTML and CSS structure should leave room for responsive breakpoints. +- The storefront rebuild is not only visual CSS work. It may require Razor view changes, model/factory changes, controller changes, admin settings, widget changes, and seed/config data. +- Product-related visuals should use existing product picture data where practical instead of being recreated as static theme images. +- Media objects must not expose raw S3, MinIO, or bucket URLs to browsers. +- AWS S3 will be used for development/test and production object storage. +- MinIO will be used in stage as an S3-compatible replacement. +- All browser-facing media URLs must go through the application, using a media proxy. +- CloudFront is intentionally out of scope because DNS is not controlled by the team. +- Existing production migration can use a planned maintenance window of roughly half a day to one day. + +## Target Architecture + +### Storage + +Create an application-level media storage abstraction instead of mounting S3 as a filesystem. + +The storage layer should support: + +- AWS S3 for test/prod. +- MinIO for stage. +- Private buckets only. +- No public object ACLs. +- Configurable endpoint, bucket, region, access key, secret, and path-style addressing. + +Proposed interface: + +```csharp +public interface IMediaStorage +{ + Task PutAsync(MediaPutRequest request, CancellationToken cancellationToken = default); + Task GetAsync(string objectKey, CancellationToken cancellationToken = default); + Task HeadAsync(string objectKey, CancellationToken cancellationToken = default); + Task DeleteAsync(string objectKey, CancellationToken cancellationToken = default); + Task ExistsAsync(string objectKey, CancellationToken cancellationToken = default); +} +``` + +### Browser-Facing URLs + +Media URLs should be stable application URLs, not object storage URLs. + +Examples: + +```text +/media/assets/{assetId}/{seoName} +/media/pictures/{pictureId}/{size}/{seoName} +/media/pictures/{pictureId}/original/{seoName} +``` + +Optional compatibility route for existing rich editor HTML: + +```text +/images/uploaded/{**path} +``` + +This route can temporarily proxy legacy uploaded paths while old HTML is migrated. + +### Media Proxy + +Add a media proxy controller responsible for reading from storage and streaming the response. + +Required behavior: + +- Stream object content without buffering entire files in memory. +- Return correct `Content-Type`. +- Return `Content-Length` when available. +- Return `ETag` and/or `Last-Modified` when available. +- Support conditional requests with `304 Not Modified`. +- Apply browser cache headers. +- Keep private object storage credentials server-side only. + +Later behavior: + +- Support HTTP `Range` requests for video and large media. +- Add authorization rules if private customer/vendor media is introduced. +- Add image transformation variants if needed beyond nopCommerce thumbnail behavior. + +## Data Model Direction + +Use explicit metadata tables rather than relying on physical paths inside HTML or S3 object keys alone. + +### Managed Media Assets + +Add a new managed media asset model for non-product-picture assets such as homepage visuals, banners, rich editor images, icons, downloadable presentation media, and future marketing media. + +Suggested fields: + +- `Id` +- `StorageProvider` +- `Bucket` +- `ObjectKey` +- `FileName` +- `SeoFileName` +- `MimeType` +- `FileSize` +- `Width` +- `Height` +- `Checksum` +- `AltText` +- `Title` +- `UsageType` +- `CreatedOnUtc` +- `UpdatedOnUtc` +- `Deleted` + +### Product Pictures + +Keep nopCommerce product picture concepts intact: + +- Keep `Picture`. +- Keep `ProductPicture`. +- Keep existing picture metadata such as MIME type, SEO filename, alt, title, and display order. + +Add storage mapping for picture binaries, either with a dedicated `PictureStorage` table or by reusing the managed media asset table with a product-picture usage type. + +Suggested picture storage fields: + +- `PictureId` +- `StorageProvider` +- `Bucket` +- `ObjectKey` +- `OriginalFileName` +- `MimeType` +- `FileSize` +- `Width` +- `Height` +- `Checksum` +- `CreatedOnUtc` + +`PictureBinary` should remain untouched during initial rollout and migration dry runs. It can be cleared only after production validation and rollback risk is acceptable. + +## Phase 1: New Frontend Theme and Media Storage Foundation + +This is the first major step. + +### Scope + +- Create the new frontend theme. +- Build the initial `IMediaStorage` abstraction. +- Implement S3-compatible storage for AWS S3 and MinIO. +- Add media proxy routes. +- Add admin-managed media assets for assets currently handled as static files. +- Update new frontend views to use managed media asset URLs instead of hardcoded static files. +- Add or adjust application data needed by the new storefront, such as homepage section configuration, featured product selections, category/application links, and managed media references. +- Adjust public model factories/controllers only when the new frontend requires data that existing models do not expose. + +### Theme Work + +- Create `Themes/Kneo`. +- Add a new `theme.json`. +- Add new frontend CSS/JS asset structure. +- Override only the required public views and partials. +- Keep `DefaultClean` available as a reference and fallback. +- Build the first version as a desktop-first layout matching the supplied design screenshots. +- Use semantic section/partial boundaries so mobile breakpoints can be added later without replacing the markup. +- Prioritize the main storefront paths: + - Home page + - Header/navigation + - Footer + - Category/listing pages + - Product cards + - Product detail page + - Cart entry points and purchase CTA surfaces + +### Media Storage Work + +- Add configuration for S3-compatible storage: + - Provider + - Endpoint + - Region + - Bucket + - Access key + - Secret key + - Force path style + - Public proxy base path +- Implement AWS S3/MinIO client code behind `IMediaStorage`. +- Add managed media metadata persistence. +- Add media proxy controller and routes. +- Add admin upload/list/edit/delete flow for managed assets. +- Add rich editor insertion path for new managed media assets. +- Add controlled ways to reference managed media from storefront sections, rather than hardcoding object keys or bucket URLs in Razor views. + +### Storefront Data Work + +The redesigned home page and other new storefront surfaces may require data that the current theme does not model explicitly. + +Possible data/config needs: + +- Home hero copy and media references. +- Featured hardware/product list. +- Recommended books/product list. +- Application domain tiles and links. +- Section display order. +- Header navigation entries. +- CTA target URLs. + +Implementation options, from simplest to most flexible: + +- Use existing nopCommerce categories/products/manufacturers where the content naturally maps to catalog data. +- Use settings for small global values such as hero title, subtitle, and CTA links. +- Use a custom table/model for repeatable home page sections if the content needs admin management. +- Use widgets only when the content needs to be independently pluggable. + +Avoid encoding business-editable homepage content directly in CSS or static HTML unless it is clearly temporary. + +### Static Asset Conversion + +Convert frontend-owned non-CSS media from static files into managed media where practical. + +Examples: + +- Homepage hero images +- Banner images +- Landing/media blocks +- Editorial image assets +- Future video poster images + +CSS-only assets can remain under the theme if they are purely presentational and not expected to change through admin workflows. + +### Out of Scope + +- Existing `PictureService` replacement. +- Existing product picture migration. +- Bulk rewriting existing product descriptions. +- Deleting `PictureBinary`. +- CloudFront integration. + +### Acceptance Criteria + +- New theme can be selected and renders core storefront pages. +- New frontend assets can be uploaded to S3/MinIO through the application. +- New frontend views render media through application proxy URLs. +- No browser-visible raw S3 or MinIO URLs. +- Stage can switch between AWS S3-compatible config and MinIO config. +- Existing product pictures still work through current nopCommerce behavior. +- Required home page data can be configured or seeded without editing Razor/CSS for normal content updates. + +## Phase 2: Apply Media Storage to Picture Service + +### Scope + +Replace product picture binary storage/read behavior with the new media storage layer while keeping existing public and admin behavior as stable as possible. + +### Work Items + +- Extend or replace `IPictureService` implementation. +- Make new product picture uploads write to S3/MinIO. +- Make `GetPictureUrlAsync` return media proxy URLs. +- Make original and generated thumbnail reads flow through the proxy. +- Decide whether thumbnails are: + - generated on demand and stored in S3/MinIO, or + - generated during upload/update and stored as variants. +- Preserve existing product picture metadata and product associations. +- Keep compatibility for pictures still stored in DB during rollout. + +### Compatibility Strategy + +During rollout, product pictures may exist in two states: + +- Legacy DB-backed picture: `PictureBinary.BinaryData` exists and no storage mapping exists. +- New storage-backed picture: storage mapping exists and object is in S3/MinIO. + +The picture service should support both until migration is complete. + +### Acceptance Criteria + +- New product image uploads are stored in S3/MinIO. +- Existing DB-backed product images still render. +- Product image URLs are application proxy URLs. +- Admin product picture add/edit/delete behavior still works. +- Category pages, product detail pages, carts, orders, and search thumbnails render correctly. + +## Phase 3: Migration Plan and Migration Tooling + +### Scope + +Build and test tools to migrate existing DB-backed product pictures and legacy rich editor uploads into the new storage system. + +### Product Picture Migration + +Migration tool responsibilities: + +- Scan `Picture` records. +- Read current binary data from `PictureBinary`. +- Generate deterministic object keys. +- Upload originals to S3/MinIO. +- Create storage mapping records. +- Verify object exists and size/checksum matches. +- Mark migration status. +- Produce a migration report. + +Suggested object key format: + +```text +pictures/{pictureId}/original/{safeSeoName-or-pictureId}.{extension} +pictures/{pictureId}/thumbs/{size}/{safeSeoName-or-pictureId}.{extension} +``` + +Thumbs may be generated lazily after migration unless performance testing shows preload is needed. + +### Rich Editor Upload Migration + +Migration tool responsibilities: + +- Scan product descriptions and other rich editor fields for `/images/uploaded/...`. +- Resolve matching local files from the current production filesystem. +- Upload files to managed media storage. +- Create managed media asset records. +- Optionally rewrite HTML to `/media/assets/...`. +- Produce a report of missing local files and rewritten references. + +If direct rewrite is risky, keep `/images/uploaded/{**path}` as a legacy proxy route during the transition. + +### Production Cutover + +Expected maintenance-window flow: + +1. Enable maintenance mode. +2. Freeze product/media edits. +3. Run product picture migration. +4. Run rich editor media migration. +5. Validate migration report. +6. Switch production config to storage-backed picture service. +7. Clear application cache. +8. Run smoke tests on critical pages. +9. Disable maintenance mode. + +### Rollback Strategy + +- Do not delete or clear `PictureBinary` during initial production cutover. +- Keep legacy local uploaded files until product verification is complete. +- Keep DB and filesystem backups from immediately before migration. +- If needed, switch config back to DB-backed picture behavior and legacy static uploaded files. + +### Acceptance Criteria + +- All existing product pictures have storage mapping records. +- All migrated objects exist in S3/MinIO. +- Product pages render after clearing browser/application cache. +- Missing rich editor files are reported explicitly. +- No raw object storage URLs appear in rendered storefront HTML. + +## Risks and Controls + +### Web Server Bandwidth + +Without CloudFront, all media flows through the application. + +Controls: + +- Stream responses. +- Use browser caching. +- Use conditional GET. +- Add Range support before serving large videos. +- Keep image sizes reasonable. + +### Cache Invalidation + +Stable URLs can cause stale browser caches when media is replaced. + +Controls: + +- Prefer immutable object keys for replaced files. +- Include version or content hash in proxy URLs when needed. +- Use `ETag` and `Last-Modified`. + +### Legacy HTML References + +Existing rich editor HTML may point to missing local files. + +Controls: + +- Build a scanner before production migration. +- Report missing files before cutover. +- Keep a compatibility proxy route. + +### S3-Compatible Differences + +AWS S3 and MinIO differ in endpoint style, path style, metadata, and local TLS behavior. + +Controls: + +- Keep all provider differences inside `S3MediaStorage`. +- Test against MinIO in stage and AWS S3 in test. +- Avoid provider-specific URLs in persisted HTML. + +## Suggested Implementation Order + +1. Add configuration and `IMediaStorage`. +2. Add S3/MinIO implementation. +3. Add media metadata tables and migrations. +4. Add media proxy read endpoint. +5. Add admin-managed media upload/list/edit flow. +6. Create `Themes/Kneo`. +7. Convert new frontend non-CSS media to managed assets. +8. Integrate managed assets with rich editor for new content. +9. Add storage-backed picture service compatibility. +10. Build migration scanner/report. +11. Build product picture migration tool. +12. Build rich editor media migration tool. +13. Run dry runs on stage. +14. Execute production migration during maintenance window. + +## Deployment Memo + +This section tracks deploy-time work that should not be forgotten as the project evolves. + +### Phase 1 Deploy Checklist + +- Add S3/MinIO configuration values for the target environment. +- Create the target bucket in AWS S3 or MinIO. +- Keep the bucket private. +- Create or provision application credentials with only the required bucket permissions. +- Confirm application config does not expose raw S3/MinIO URLs to rendered HTML. +- Run database migrations for managed media metadata. +- Seed required storefront settings/data: + - active theme set to `Kneo` + - home hero configuration + - featured product/book selections + - application tiles + - navigation entries if they are data-driven +- Upload required non-product homepage media into managed media storage. +- Verify media proxy can read from S3/MinIO in the deployed environment. +- Verify cache headers and conditional GET behavior on media responses. +- Confirm existing product pictures still render through the legacy picture path. +- Clear application cache after theme/settings changes. +- Smoke test: + - home page + - category/listing page + - product detail page + - cart entry point + - admin media upload/list + +### Phase 2 Deploy Checklist + +- Run database migrations for picture storage mapping. +- Enable storage-backed picture service in the target environment. +- Confirm new product picture uploads write to S3/MinIO. +- Confirm legacy DB-backed product pictures still render. +- Verify generated thumbnail behavior. +- Clear picture/model caches. +- Smoke test: + - product image upload in admin + - product detail gallery + - product card thumbnails + - cart/order thumbnails + +### Phase 3 Migration Checklist + +- Take database backup. +- Back up legacy local media directories, especially `wwwroot/images/uploaded`. +- Enable maintenance mode. +- Freeze product and media edits. +- Run migration dry-run/report first if possible. +- Run product picture migration. +- Run rich editor media migration. +- Verify migration counts and missing-file report. +- Switch production config to storage-backed picture service. +- Clear application and CDN/browser-relevant caches where applicable. +- Smoke test critical storefront and admin pages. +- Keep `PictureBinary` data and legacy uploaded files until post-cutover validation is complete. +- Disable maintenance mode. diff --git a/docs/operations-memo.md b/docs/operations-memo.md new file mode 100644 index 0000000..a1f0319 --- /dev/null +++ b/docs/operations-memo.md @@ -0,0 +1,48 @@ +# 操作備忘錄(通用) + +## 用途 + +本文件用於記錄「部署或資料調整時不可漏掉的操作步驟」。 +不限定分類調整,後續所有會影響既有資料或上線流程的改動都追加在此。 + +--- + +## 2026-05-03 ~ 2026-05-05:分類骨架調整與首頁資料前置 + +### 執行目標 + +1. 建立新分類骨架:`Hardware`、`Software`、`AI Application`(6 子類)、`Books`(3 子類) +2. 補齊分類中文多語系 +3. 處理舊 `Software` 子類別清理 +4. 保留可回滾資料 + +### SQL 檔案 + +1. [category-restructure-20260503.sql](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/sql/category-restructure-20260503.sql) +2. [category-delete-legacy-software-children-20260503.sql](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/sql/category-delete-legacy-software-children-20260503.sql) +3. [category-localization-backfill-tw-20260503.sql](/Users/chenyanhan/Documents/Projects/Kneo_marketplace/docs/sql/category-localization-backfill-tw-20260503.sql) + +### 操作重點 + +1. 先執行分類重構 SQL,再執行舊子類清理 SQL。 +2. 語系代碼需支援 `tw`,否則中文多語系不會落地。 +3. `Software` 下舊子類刪除邏輯已改為「依父節點刪除」,不再依名稱比對。 +4. 刪除前會備份 `Category`、`Product_Category_Mapping`、`LocalizedProperty`、`UrlRecord` 到備份表。 + +### 手動設定 + +1. 後台手動調整分類顯示順序:`Hardware = 10`、`Software = 20`。 +2. 前台若未即時反映中文名稱,需清除應用程式快取。 + +### 驗證項目 + +1. `AI Application` 下應有 6 個子類。 +2. `Books` 下應有 3 個子類(英文預設名,中文靠多語系)。 +3. `Software` 下子類應為空(新骨架目標)。 +4. 13 個目標分類的中文多語系需存在於 `LocalizedProperty`。 + +### 回滾原則 + +1. 先恢復分類可見性或重新建立父子關係。 +2. 若資料已刪除,從備份表回補 `Category`、`Product_Category_Mapping`、`LocalizedProperty`、`UrlRecord`。 + diff --git a/src/Libraries/Nop.Core/Domain/Catalog/HomepageHeroProduct.cs b/src/Libraries/Nop.Core/Domain/Catalog/HomepageHeroProduct.cs new file mode 100644 index 0000000..eae6671 --- /dev/null +++ b/src/Libraries/Nop.Core/Domain/Catalog/HomepageHeroProduct.cs @@ -0,0 +1,14 @@ +namespace Nop.Core.Domain.Catalog; + +/// +/// Homepage hero product mapping +/// +public partial class HomepageHeroProduct : BaseEntity +{ + public int ProductId { get; set; } + public bool IsEnabled { get; set; } + public int DisplayOrder { get; set; } + public DateTime CreatedOnUtc { get; set; } + public DateTime UpdatedOnUtc { get; set; } +} + diff --git a/src/Libraries/Nop.Core/Domain/Catalog/NopProductPrDefaults.cs b/src/Libraries/Nop.Core/Domain/Catalog/NopProductPrDefaults.cs new file mode 100644 index 0000000..09bdfd5 --- /dev/null +++ b/src/Libraries/Nop.Core/Domain/Catalog/NopProductPrDefaults.cs @@ -0,0 +1,25 @@ +namespace Nop.Core.Domain.Catalog; + +/// +/// Product PR-related attribute defaults +/// +public static partial class NopProductPrDefaults +{ + public static string HeroEnabledAttribute => "Product.Hero.Enabled"; + public static string HeroTitleLine1Attribute => "Product.Hero.TitleLine1"; + public static string HeroTitleLine2Attribute => "Product.Hero.TitleLine2"; + public static string HeroFeatureLine1Attribute => "Product.Hero.FeatureLine1"; + public static string HeroFeatureLine2Attribute => "Product.Hero.FeatureLine2"; + public static string HeroPrCopyAttribute => "Product.Hero.PrCopy"; + public static string HeroTitleLine1FontSizeAttribute => "Product.Hero.TitleLine1FontSize"; + public static string HeroTitleLine2FontSizeAttribute => "Product.Hero.TitleLine2FontSize"; + public static string HeroFeatureLine1FontSizeAttribute => "Product.Hero.FeatureLine1FontSize"; + public static string HeroFeatureLine2FontSizeAttribute => "Product.Hero.FeatureLine2FontSize"; + public static string HeroPrCopyFontSizeAttribute => "Product.Hero.PrCopyFontSize"; + + public static string GetLocalizedKey(string key, int languageId) + { + return $"{key}.{languageId}"; + } +} + diff --git a/src/Libraries/Nop.Data/Mapping/Builders/Catalog/HomepageHeroProductBuilder.cs b/src/Libraries/Nop.Data/Mapping/Builders/Catalog/HomepageHeroProductBuilder.cs new file mode 100644 index 0000000..19069b3 --- /dev/null +++ b/src/Libraries/Nop.Data/Mapping/Builders/Catalog/HomepageHeroProductBuilder.cs @@ -0,0 +1,28 @@ +using FluentMigrator.Builders.Create.Table; +using Nop.Core.Domain.Catalog; +using Nop.Data.Extensions; + +namespace Nop.Data.Mapping.Builders.Catalog; + +/// +/// Represents a homepage hero product entity builder +/// +public partial class HomepageHeroProductBuilder : NopEntityBuilder +{ + #region Methods + + /// + /// Apply entity configuration + /// + /// Create table expression builder + public override void MapEntity(CreateTableExpressionBuilder table) + { + table + .WithColumn(nameof(HomepageHeroProduct.ProductId)).AsInt32().ForeignKey() + .WithColumn(nameof(HomepageHeroProduct.IsEnabled)).AsBoolean().NotNullable() + .WithColumn(nameof(HomepageHeroProduct.DisplayOrder)).AsInt32().NotNullable(); + } + + #endregion +} + diff --git a/src/Libraries/Nop.Data/Migrations/UpgradeTo470/HomepageHeroProductMigration.cs b/src/Libraries/Nop.Data/Migrations/UpgradeTo470/HomepageHeroProductMigration.cs new file mode 100644 index 0000000..e4f6f55 --- /dev/null +++ b/src/Libraries/Nop.Data/Migrations/UpgradeTo470/HomepageHeroProductMigration.cs @@ -0,0 +1,16 @@ +using FluentMigrator; +using Nop.Core.Domain.Catalog; +using Nop.Data.Extensions; +using Nop.Data.Mapping; + +namespace Nop.Data.Migrations.UpgradeTo470; + +[NopSchemaMigration("2026-05-05 10:00:00", "Add homepage hero product mapping table")] +public class HomepageHeroProductMigration : ForwardOnlyMigration +{ + public override void Up() + { + if (!Schema.Table(nameof(HomepageHeroProduct)).Exists()) + Create.TableFor(); + } +} diff --git a/src/Libraries/Nop.Data/Migrations/UpgradeTo470/SchemaMigration.cs b/src/Libraries/Nop.Data/Migrations/UpgradeTo470/SchemaMigration.cs index 4f67f55..6f85276 100755 --- a/src/Libraries/Nop.Data/Migrations/UpgradeTo470/SchemaMigration.cs +++ b/src/Libraries/Nop.Data/Migrations/UpgradeTo470/SchemaMigration.cs @@ -102,5 +102,8 @@ public class SchemaMigration : ForwardOnlyMigration if (!Schema.Table(newsLetterSubscriptionTableName).Column(nameof(NewsLetterSubscription.LanguageId)).Exists()) Alter.Table(newsLetterSubscriptionTableName) .AddColumn(nameof(NewsLetterSubscription.LanguageId)).AsInt32().NotNullable().SetExistingRowsTo(1); + + if (!Schema.Table(nameof(HomepageHeroProduct)).Exists()) + Create.TableFor(); } -} \ No newline at end of file +} diff --git a/src/Libraries/Nop.Services/Catalog/HomepageHeroProductService.cs b/src/Libraries/Nop.Services/Catalog/HomepageHeroProductService.cs new file mode 100644 index 0000000..df64dad --- /dev/null +++ b/src/Libraries/Nop.Services/Catalog/HomepageHeroProductService.cs @@ -0,0 +1,49 @@ +using LinqToDB; +using Nop.Core.Domain.Catalog; +using Nop.Data; + +namespace Nop.Services.Catalog; + +public partial class HomepageHeroProductService : IHomepageHeroProductService +{ + private readonly IRepository _homepageHeroProductRepository; + + public HomepageHeroProductService(IRepository homepageHeroProductRepository) + { + _homepageHeroProductRepository = homepageHeroProductRepository; + } + + public virtual async Task> GetAllAsync(bool onlyEnabled = false) + { + var query = _homepageHeroProductRepository.Table; + if (onlyEnabled) + query = query.Where(x => x.IsEnabled); + + return await query.OrderBy(x => x.DisplayOrder).ThenBy(x => x.Id).ToListAsync(); + } + + public virtual async Task GetByIdAsync(int id) + { + return await _homepageHeroProductRepository.GetByIdAsync(id); + } + + public virtual async Task GetByProductIdAsync(int productId) + { + return await _homepageHeroProductRepository.Table.FirstOrDefaultAsync(x => x.ProductId == productId); + } + + public virtual async Task InsertAsync(HomepageHeroProduct entity) + { + await _homepageHeroProductRepository.InsertAsync(entity); + } + + public virtual async Task UpdateAsync(HomepageHeroProduct entity) + { + await _homepageHeroProductRepository.UpdateAsync(entity); + } + + public virtual async Task DeleteAsync(HomepageHeroProduct entity) + { + await _homepageHeroProductRepository.DeleteAsync(entity); + } +} diff --git a/src/Libraries/Nop.Services/Catalog/IHomepageHeroProductService.cs b/src/Libraries/Nop.Services/Catalog/IHomepageHeroProductService.cs new file mode 100644 index 0000000..35ed7a0 --- /dev/null +++ b/src/Libraries/Nop.Services/Catalog/IHomepageHeroProductService.cs @@ -0,0 +1,14 @@ +using Nop.Core.Domain.Catalog; + +namespace Nop.Services.Catalog; + +public partial interface IHomepageHeroProductService +{ + Task> GetAllAsync(bool onlyEnabled = false); + Task GetByIdAsync(int id); + Task GetByProductIdAsync(int productId); + Task InsertAsync(HomepageHeroProduct entity); + Task UpdateAsync(HomepageHeroProduct entity); + Task DeleteAsync(HomepageHeroProduct entity); +} + diff --git a/src/Libraries/Nop.Services/Catalog/ProductService.cs b/src/Libraries/Nop.Services/Catalog/ProductService.cs index 4ba1dfa..117dbe3 100755 --- a/src/Libraries/Nop.Services/Catalog/ProductService.cs +++ b/src/Libraries/Nop.Services/Catalog/ProductService.cs @@ -1283,10 +1283,10 @@ public partial class ProductService : IProductService //filter by products with tracking inventory query = query.Where(product => product.ManageInventoryMethodId == (int)ManageInventoryMethod.ManageStock); - //filter by products with stock quantity less than the minimum + //filter by products with stock quantity less than or equal to the notification threshold query = query.Where(product => (product.UseMultipleWarehouses ? _productWarehouseInventoryRepository.Table.Where(pwi => pwi.ProductId == product.Id).Sum(pwi => pwi.StockQuantity - pwi.ReservedQuantity) - : product.StockQuantity) <= product.MinStockQuantity); + : product.StockQuantity) <= product.NotifyAdminForQuantityBelow); //ignore deleted products query = query.Where(product => !product.Deleted); @@ -1302,7 +1302,7 @@ public partial class ProductService : IProductService if (loadPublishedOnly.HasValue) query = query.Where(product => product.Published == loadPublishedOnly.Value); - query = query.OrderBy(product => product.MinStockQuantity).ThenBy(product => product.DisplayOrder).ThenBy(product => product.Id); + query = query.OrderBy(product => product.NotifyAdminForQuantityBelow).ThenBy(product => product.DisplayOrder).ThenBy(product => product.Id); return await query.ToPagedListAsync(pageIndex, pageSize, getOnlyTotalCount); } @@ -1325,8 +1325,8 @@ public partial class ProductService : IProductService var combinations = from pac in _productAttributeCombinationRepository.Table join p in _productRepository.Table on pac.ProductId equals p.Id where - //filter by combinations with stock quantity less than the minimum - pac.StockQuantity <= pac.MinStockQuantity && + //filter by combinations with stock quantity less than or equal to the notification threshold + pac.StockQuantity <= pac.NotifyAdminForQuantityBelow && //filter by products with tracking inventory by attributes p.ManageInventoryMethodId == (int)ManageInventoryMethod.ManageStockByAttributes && //ignore deleted products @@ -2847,4 +2847,4 @@ public partial class ProductService : IProductService #endregion #endregion -} \ No newline at end of file +} diff --git a/src/Presentation/Nop.Web.Framework/Infrastructure/NopStartup.cs b/src/Presentation/Nop.Web.Framework/Infrastructure/NopStartup.cs index 6334682..4d9fde4 100755 --- a/src/Presentation/Nop.Web.Framework/Infrastructure/NopStartup.cs +++ b/src/Presentation/Nop.Web.Framework/Infrastructure/NopStartup.cs @@ -145,6 +145,7 @@ public partial class NopStartup : INopStartup services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -327,4 +328,4 @@ public partial class NopStartup : INopStartup /// Gets order of this startup configuration implementation /// public int Order => 2000; -} \ No newline at end of file +} diff --git a/src/Presentation/Nop.Web/App_Data/appsettings.json b/src/Presentation/Nop.Web/App_Data/appsettings.json index fc9f5ea..78027e7 100644 --- a/src/Presentation/Nop.Web/App_Data/appsettings.json +++ b/src/Presentation/Nop.Web/App_Data/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "ConnectionString": "Host=192.168.0.130;Database=kneo;Username=kneoweb;Password=kneo!admin", + "ConnectionString": "Host=192.168.0.130;Database=kneo_dev;Username=kneoweb;Password=kneo!admin", "DataProvider": "postgresql", "SQLCommandTimeout": null, "WithNoLock": false diff --git a/src/Presentation/Nop.Web/Areas/Admin/Controllers/ProductController.cs b/src/Presentation/Nop.Web/Areas/Admin/Controllers/ProductController.cs index 4a7346f..605aa97 100755 --- a/src/Presentation/Nop.Web/Areas/Admin/Controllers/ProductController.cs +++ b/src/Presentation/Nop.Web/Areas/Admin/Controllers/ProductController.cs @@ -66,6 +66,7 @@ public partial class ProductController : BaseAdminController protected readonly IProductModelFactory _productModelFactory; protected readonly IProductService _productService; protected readonly IProductTagService _productTagService; + protected readonly IHomepageHeroProductService _homepageHeroProductService; protected readonly ISettingService _settingService; protected readonly IShippingService _shippingService; protected readonly IShoppingCartService _shoppingCartService; @@ -109,6 +110,7 @@ public partial class ProductController : BaseAdminController IProductModelFactory productModelFactory, IProductService productService, IProductTagService productTagService, + IHomepageHeroProductService homepageHeroProductService, ISettingService settingService, IShippingService shippingService, IShoppingCartService shoppingCartService, @@ -147,6 +149,7 @@ public partial class ProductController : BaseAdminController _productModelFactory = productModelFactory; _productService = productService; _productTagService = productTagService; + _homepageHeroProductService = homepageHeroProductService; _settingService = settingService; _shippingService = shippingService; _shoppingCartService = shoppingCartService; @@ -195,9 +198,55 @@ public partial class ProductController : BaseAdminController //search engine name var seName = await _urlRecordService.ValidateSeNameAsync(product, localized.SeName, localized.Name, false); await _urlRecordService.SaveSlugAsync(product, seName, localized.LanguageId); + + await _genericAttributeService.SaveAttributeAsync(product, + NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine1Attribute, localized.LanguageId), + localized.HeroTitleLine1); + await _genericAttributeService.SaveAttributeAsync(product, + NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine2Attribute, localized.LanguageId), + localized.HeroTitleLine2); + await _genericAttributeService.SaveAttributeAsync(product, + NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine1Attribute, localized.LanguageId), + localized.HeroFeatureLine1); + await _genericAttributeService.SaveAttributeAsync(product, + NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine2Attribute, localized.LanguageId), + localized.HeroFeatureLine2); + await _genericAttributeService.SaveAttributeAsync(product, + NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroPrCopyAttribute, localized.LanguageId), + localized.HeroPrCopy); + await _genericAttributeService.SaveAttributeAsync(product, + NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine1FontSizeAttribute, localized.LanguageId), + localized.HeroTitleLine1FontSize); + await _genericAttributeService.SaveAttributeAsync(product, + NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine2FontSizeAttribute, localized.LanguageId), + localized.HeroTitleLine2FontSize); + await _genericAttributeService.SaveAttributeAsync(product, + NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine1FontSizeAttribute, localized.LanguageId), + localized.HeroFeatureLine1FontSize); + await _genericAttributeService.SaveAttributeAsync(product, + NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine2FontSizeAttribute, localized.LanguageId), + localized.HeroFeatureLine2FontSize); + await _genericAttributeService.SaveAttributeAsync(product, + NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroPrCopyFontSizeAttribute, localized.LanguageId), + localized.HeroPrCopyFontSize); } } + protected virtual async Task SaveHardwarePrAttributesAsync(Product product, ProductModel model) + { + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroEnabledAttribute, model.HeroEnabled); + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroTitleLine1Attribute, model.HeroTitleLine1); + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroTitleLine2Attribute, model.HeroTitleLine2); + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroFeatureLine1Attribute, model.HeroFeatureLine1); + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroFeatureLine2Attribute, model.HeroFeatureLine2); + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroPrCopyAttribute, model.HeroPrCopy); + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroTitleLine1FontSizeAttribute, model.HeroTitleLine1FontSize); + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroTitleLine2FontSizeAttribute, model.HeroTitleLine2FontSize); + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroFeatureLine1FontSizeAttribute, model.HeroFeatureLine1FontSize); + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroFeatureLine2FontSizeAttribute, model.HeroFeatureLine2FontSize); + await _genericAttributeService.SaveAttributeAsync(product, NopProductPrDefaults.HeroPrCopyFontSizeAttribute, model.HeroPrCopyFontSize); + } + protected virtual async Task UpdateLocalesAsync(ProductTag productTag, ProductTagModel model) { foreach (var localized in model.Locales) @@ -919,6 +968,8 @@ public partial class ProductController : BaseAdminController //stores await _productService.UpdateProductStoreMappingsAsync(product, model.SelectedStoreIds); + await SaveHardwarePrAttributesAsync(product, model); + //discounts await SaveDiscountMappingsAsync(product, model); @@ -1062,6 +1113,8 @@ public partial class ProductController : BaseAdminController //stores await _productService.UpdateProductStoreMappingsAsync(product, model.SelectedStoreIds); + await SaveHardwarePrAttributesAsync(product, model); + //discounts await SaveDiscountMappingsAsync(product, model); @@ -1175,6 +1228,108 @@ public partial class ProductController : BaseAdminController return RedirectToAction("List"); } + public virtual async Task HomepageHero() + { + if (!await _permissionService.AuthorizeAsync(StandardPermissionProvider.ManageProducts)) + return AccessDeniedView(); + + var mappings = await _homepageHeroProductService.GetAllAsync(); + var model = new HomepageHeroProductListModel(); + + foreach (var item in mappings) + { + var product = await _productService.GetProductByIdAsync(item.ProductId); + if (product == null || product.Deleted) + continue; + + model.Items.Add(new HomepageHeroProductModel + { + Id = item.Id, + ProductId = item.ProductId, + ProductName = product.Name, + IsEnabled = item.IsEnabled, + DisplayOrder = item.DisplayOrder + }); + } + + return View(model); + } + + [HttpPost] + public virtual async Task HomepageHeroAdd(HomepageHeroProductListModel model) + { + if (!await _permissionService.AuthorizeAsync(StandardPermissionProvider.ManageProducts)) + return AccessDeniedView(); + + if (model.AddProductId <= 0) + { + _notificationService.ErrorNotification("請輸入商品 ID"); + return RedirectToAction(nameof(HomepageHero)); + } + + var product = await _productService.GetProductByIdAsync(model.AddProductId); + if (product == null || product.Deleted) + { + _notificationService.ErrorNotification("找不到商品"); + return RedirectToAction(nameof(HomepageHero)); + } + + var existing = await _homepageHeroProductService.GetByProductIdAsync(model.AddProductId); + if (existing != null) + { + _notificationService.WarningNotification("該商品已在首頁輪播清單中"); + return RedirectToAction(nameof(HomepageHero)); + } + + await _homepageHeroProductService.InsertAsync(new HomepageHeroProduct + { + ProductId = model.AddProductId, + IsEnabled = true, + DisplayOrder = 0, + CreatedOnUtc = DateTime.UtcNow, + UpdatedOnUtc = DateTime.UtcNow + }); + + _notificationService.SuccessNotification("已加入首頁輪播清單"); + return RedirectToAction(nameof(HomepageHero)); + } + + [HttpPost] + public virtual async Task HomepageHeroUpdate(HomepageHeroProductListModel model) + { + if (!await _permissionService.AuthorizeAsync(StandardPermissionProvider.ManageProducts)) + return AccessDeniedView(); + + foreach (var item in model.Items) + { + var entity = await _homepageHeroProductService.GetByIdAsync(item.Id); + if (entity == null) + continue; + + entity.IsEnabled = item.IsEnabled; + entity.DisplayOrder = item.DisplayOrder; + entity.UpdatedOnUtc = DateTime.UtcNow; + await _homepageHeroProductService.UpdateAsync(entity); + } + + _notificationService.SuccessNotification("首頁輪播設定已更新"); + return RedirectToAction(nameof(HomepageHero)); + } + + [HttpPost] + public virtual async Task HomepageHeroDelete(int id) + { + if (!await _permissionService.AuthorizeAsync(StandardPermissionProvider.ManageProducts)) + return AccessDeniedView(); + + var entity = await _homepageHeroProductService.GetByIdAsync(id); + if (entity != null) + await _homepageHeroProductService.DeleteAsync(entity); + + _notificationService.SuccessNotification("已移除輪播商品"); + return RedirectToAction(nameof(HomepageHero)); + } + [HttpPost] public virtual async Task DeleteSelected(ICollection selectedIds) { @@ -3768,4 +3923,4 @@ public partial class ProductController : BaseAdminController #endregion #endregion -} \ No newline at end of file +} diff --git a/src/Presentation/Nop.Web/Areas/Admin/Factories/ProductModelFactory.cs b/src/Presentation/Nop.Web/Areas/Admin/Factories/ProductModelFactory.cs index a048856..483e37c 100755 --- a/src/Presentation/Nop.Web/Areas/Admin/Factories/ProductModelFactory.cs +++ b/src/Presentation/Nop.Web/Areas/Admin/Factories/ProductModelFactory.cs @@ -53,6 +53,7 @@ public partial class ProductModelFactory : IProductModelFactory protected readonly IDateTimeHelper _dateTimeHelper; protected readonly IDiscountService _discountService; protected readonly IDiscountSupportedModelFactory _discountSupportedModelFactory; + protected readonly IGenericAttributeService _genericAttributeService; protected readonly ILocalizationService _localizationService; protected readonly ILocalizedModelFactory _localizedModelFactory; protected readonly IManufacturerService _manufacturerService; @@ -97,6 +98,7 @@ public partial class ProductModelFactory : IProductModelFactory IDateTimeHelper dateTimeHelper, IDiscountService discountService, IDiscountSupportedModelFactory discountSupportedModelFactory, + IGenericAttributeService genericAttributeService, ILocalizationService localizationService, ILocalizedModelFactory localizedModelFactory, IManufacturerService manufacturerService, @@ -137,6 +139,7 @@ public partial class ProductModelFactory : IProductModelFactory _dateTimeHelper = dateTimeHelper; _discountService = discountService; _discountSupportedModelFactory = discountSupportedModelFactory; + _genericAttributeService = genericAttributeService; _localizationService = localizationService; _localizedModelFactory = localizedModelFactory; _manufacturerService = manufacturerService; @@ -811,6 +814,18 @@ public partial class ProductModelFactory : IProductModelFactory model.CanCreateCombinations = await (await _productAttributeService .GetProductAttributeMappingsByProductIdAsync(product.Id)).AnyAwaitAsync(async pam => (await _productAttributeService.GetProductAttributeValuesAsync(pam.Id)).Any()); + model.HeroEnabled = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroEnabledAttribute); + model.HeroTitleLine1 = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroTitleLine1Attribute); + model.HeroTitleLine2 = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroTitleLine2Attribute); + model.HeroFeatureLine1 = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroFeatureLine1Attribute); + model.HeroFeatureLine2 = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroFeatureLine2Attribute); + model.HeroPrCopy = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroPrCopyAttribute); + model.HeroTitleLine1FontSize = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroTitleLine1FontSizeAttribute); + model.HeroTitleLine2FontSize = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroTitleLine2FontSizeAttribute); + model.HeroFeatureLine1FontSize = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroFeatureLine1FontSizeAttribute); + model.HeroFeatureLine2FontSize = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroFeatureLine2FontSizeAttribute); + model.HeroPrCopyFontSize = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.HeroPrCopyFontSizeAttribute); + if (!excludeProperties) { model.SelectedCategoryIds = (await _categoryService.GetProductCategoriesByProductIdAsync(product.Id, true)) @@ -845,6 +860,16 @@ public partial class ProductModelFactory : IProductModelFactory locale.MetaDescription = await _localizationService.GetLocalizedAsync(product, entity => entity.MetaDescription, languageId, false, false); locale.MetaTitle = await _localizationService.GetLocalizedAsync(product, entity => entity.MetaTitle, languageId, false, false); locale.SeName = await _urlRecordService.GetSeNameAsync(product, languageId, false, false); + locale.HeroTitleLine1 = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine1Attribute, languageId)); + locale.HeroTitleLine2 = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine2Attribute, languageId)); + locale.HeroFeatureLine1 = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine1Attribute, languageId)); + locale.HeroFeatureLine2 = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine2Attribute, languageId)); + locale.HeroPrCopy = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroPrCopyAttribute, languageId)); + locale.HeroTitleLine1FontSize = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine1FontSizeAttribute, languageId)); + locale.HeroTitleLine2FontSize = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine2FontSizeAttribute, languageId)); + locale.HeroFeatureLine1FontSize = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine1FontSizeAttribute, languageId)); + locale.HeroFeatureLine2FontSize = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine2FontSizeAttribute, languageId)); + locale.HeroPrCopyFontSize = await _genericAttributeService.GetAttributeAsync(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroPrCopyFontSizeAttribute, languageId)); }; } @@ -2405,4 +2430,4 @@ public partial class ProductModelFactory : IProductModelFactory } #endregion -} \ No newline at end of file +} diff --git a/src/Presentation/Nop.Web/Areas/Admin/Models/Catalog/HomepageHeroProductModel.cs b/src/Presentation/Nop.Web/Areas/Admin/Models/Catalog/HomepageHeroProductModel.cs new file mode 100644 index 0000000..ed9b92a --- /dev/null +++ b/src/Presentation/Nop.Web/Areas/Admin/Models/Catalog/HomepageHeroProductModel.cs @@ -0,0 +1,23 @@ +using Nop.Web.Framework.Models; + +namespace Nop.Web.Areas.Admin.Models.Catalog; + +public partial record HomepageHeroProductModel : BaseNopEntityModel +{ + public int ProductId { get; set; } + public string ProductName { get; set; } + public bool IsEnabled { get; set; } + public int DisplayOrder { get; set; } +} + +public partial record HomepageHeroProductListModel : BaseNopModel +{ + public HomepageHeroProductListModel() + { + Items = new List(); + } + + public IList Items { get; set; } + public int AddProductId { get; set; } +} + diff --git a/src/Presentation/Nop.Web/Areas/Admin/Models/Catalog/ProductModel.cs b/src/Presentation/Nop.Web/Areas/Admin/Models/Catalog/ProductModel.cs index 7ca7553..c0bc518 100755 --- a/src/Presentation/Nop.Web/Areas/Admin/Models/Catalog/ProductModel.cs +++ b/src/Presentation/Nop.Web/Areas/Admin/Models/Catalog/ProductModel.cs @@ -113,6 +113,24 @@ public partial record ProductModel : BaseNopEntityModel, [NopResourceDisplayName("Admin.Catalog.Products.Fields.ShowOnHomepage")] public bool ShowOnHomepage { get; set; } + //hardware PR + public bool HeroEnabled { get; set; } + public string HeroTitleLine1 { get; set; } + public string HeroTitleLine2 { get; set; } + public string HeroFeatureLine1 { get; set; } + public string HeroFeatureLine2 { get; set; } + public string HeroPrCopy { get; set; } + [UIHint("Int32Nullable")] + public int? HeroTitleLine1FontSize { get; set; } + [UIHint("Int32Nullable")] + public int? HeroTitleLine2FontSize { get; set; } + [UIHint("Int32Nullable")] + public int? HeroFeatureLine1FontSize { get; set; } + [UIHint("Int32Nullable")] + public int? HeroFeatureLine2FontSize { get; set; } + [UIHint("Int32Nullable")] + public int? HeroPrCopyFontSize { get; set; } + [NopResourceDisplayName("Admin.Catalog.Products.Fields.MetaKeywords")] public string MetaKeywords { get; set; } @@ -505,4 +523,21 @@ public partial record ProductLocalizedModel : ILocalizedLocaleModel [NopResourceDisplayName("Admin.Catalog.Products.Fields.SeName")] public string SeName { get; set; } -} \ No newline at end of file + + //hardware PR + public string HeroTitleLine1 { get; set; } + public string HeroTitleLine2 { get; set; } + public string HeroFeatureLine1 { get; set; } + public string HeroFeatureLine2 { get; set; } + public string HeroPrCopy { get; set; } + [UIHint("Int32Nullable")] + public int? HeroTitleLine1FontSize { get; set; } + [UIHint("Int32Nullable")] + public int? HeroTitleLine2FontSize { get; set; } + [UIHint("Int32Nullable")] + public int? HeroFeatureLine1FontSize { get; set; } + [UIHint("Int32Nullable")] + public int? HeroFeatureLine2FontSize { get; set; } + [UIHint("Int32Nullable")] + public int? HeroPrCopyFontSize { get; set; } +} diff --git a/src/Presentation/Nop.Web/Areas/Admin/Views/Product/HomepageHero.cshtml b/src/Presentation/Nop.Web/Areas/Admin/Views/Product/HomepageHero.cshtml new file mode 100644 index 0000000..46c1e80 --- /dev/null +++ b/src/Presentation/Nop.Web/Areas/Admin/Views/Product/HomepageHero.cshtml @@ -0,0 +1,79 @@ +@model HomepageHeroProductListModel + +@{ + ViewBag.PageTitle = "首頁輪播商品"; + NopHtml.SetActiveMenuItemSystemName("Products"); +} + +
+
+
+
+
+

新增輪播商品

+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+

輪播清單

+
+
+ + + + + + + + + + + + @for (var i = 0; i < Model.Items.Count; i++) + { + + + + + + + + } + +
ID商品啟用順序操作
+ @Model.Items[i].ProductId + + + + + @Model.Items[i].ProductName + + + + + + +
+
+ +
+
+
+
+ diff --git a/src/Presentation/Nop.Web/Areas/Admin/Views/Product/List.cshtml b/src/Presentation/Nop.Web/Areas/Admin/Views/Product/List.cshtml index 3c4038a..09624cf 100755 --- a/src/Presentation/Nop.Web/Areas/Admin/Views/Product/List.cshtml +++ b/src/Presentation/Nop.Web/Areas/Admin/Views/Product/List.cshtml @@ -26,6 +26,10 @@ @T("Admin.Common.AddNew") + + + 首頁輪播 +