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.
14
docs/category-restructure-memo.md
Normal file
@ -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)
|
||||
142
docs/change-log.md
Normal file
@ -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)。
|
||||
474
docs/frontend-media-storage-plan.md
Normal file
@ -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<MediaObjectInfo> PutAsync(MediaPutRequest request, CancellationToken cancellationToken = default);
|
||||
Task<MediaReadResult> GetAsync(string objectKey, CancellationToken cancellationToken = default);
|
||||
Task<MediaObjectInfo?> HeadAsync(string objectKey, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string objectKey, CancellationToken cancellationToken = default);
|
||||
Task<bool> 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.
|
||||
48
docs/operations-memo.md
Normal file
@ -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`。
|
||||
|
||||
14
src/Libraries/Nop.Core/Domain/Catalog/HomepageHeroProduct.cs
Normal file
@ -0,0 +1,14 @@
|
||||
namespace Nop.Core.Domain.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Homepage hero product mapping
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
namespace Nop.Core.Domain.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Product PR-related attribute defaults
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
using FluentMigrator.Builders.Create.Table;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Data.Extensions;
|
||||
|
||||
namespace Nop.Data.Mapping.Builders.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a homepage hero product entity builder
|
||||
/// </summary>
|
||||
public partial class HomepageHeroProductBuilder : NopEntityBuilder<HomepageHeroProduct>
|
||||
{
|
||||
#region Methods
|
||||
|
||||
/// <summary>
|
||||
/// Apply entity configuration
|
||||
/// </summary>
|
||||
/// <param name="table">Create table expression builder</param>
|
||||
public override void MapEntity(CreateTableExpressionBuilder table)
|
||||
{
|
||||
table
|
||||
.WithColumn(nameof(HomepageHeroProduct.ProductId)).AsInt32().ForeignKey<Product>()
|
||||
.WithColumn(nameof(HomepageHeroProduct.IsEnabled)).AsBoolean().NotNullable()
|
||||
.WithColumn(nameof(HomepageHeroProduct.DisplayOrder)).AsInt32().NotNullable();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -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<HomepageHeroProduct>();
|
||||
}
|
||||
}
|
||||
@ -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<HomepageHeroProduct>();
|
||||
}
|
||||
}
|
||||
@ -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<HomepageHeroProduct> _homepageHeroProductRepository;
|
||||
|
||||
public HomepageHeroProductService(IRepository<HomepageHeroProduct> homepageHeroProductRepository)
|
||||
{
|
||||
_homepageHeroProductRepository = homepageHeroProductRepository;
|
||||
}
|
||||
|
||||
public virtual async Task<IList<HomepageHeroProduct>> 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<HomepageHeroProduct> GetByIdAsync(int id)
|
||||
{
|
||||
return await _homepageHeroProductRepository.GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public virtual async Task<HomepageHeroProduct> 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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
using Nop.Core.Domain.Catalog;
|
||||
|
||||
namespace Nop.Services.Catalog;
|
||||
|
||||
public partial interface IHomepageHeroProductService
|
||||
{
|
||||
Task<IList<HomepageHeroProduct>> GetAllAsync(bool onlyEnabled = false);
|
||||
Task<HomepageHeroProduct> GetByIdAsync(int id);
|
||||
Task<HomepageHeroProduct> GetByProductIdAsync(int productId);
|
||||
Task InsertAsync(HomepageHeroProduct entity);
|
||||
Task UpdateAsync(HomepageHeroProduct entity);
|
||||
Task DeleteAsync(HomepageHeroProduct entity);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -145,6 +145,7 @@ public partial class NopStartup : INopStartup
|
||||
services.AddScoped<IProductAttributeParser, ProductAttributeParser>();
|
||||
services.AddScoped<IProductAttributeService, ProductAttributeService>();
|
||||
services.AddScoped<IProductService, ProductService>();
|
||||
services.AddScoped<IHomepageHeroProductService, HomepageHeroProductService>();
|
||||
services.AddScoped<ICopyProductService, CopyProductService>();
|
||||
services.AddScoped<ISpecificationAttributeService, SpecificationAttributeService>();
|
||||
services.AddScoped<IProductTemplateService, ProductTemplateService>();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> DeleteSelected(ICollection<int> selectedIds)
|
||||
{
|
||||
|
||||
@ -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<bool>(product, NopProductPrDefaults.HeroEnabledAttribute);
|
||||
model.HeroTitleLine1 = await _genericAttributeService.GetAttributeAsync<string>(product, NopProductPrDefaults.HeroTitleLine1Attribute);
|
||||
model.HeroTitleLine2 = await _genericAttributeService.GetAttributeAsync<string>(product, NopProductPrDefaults.HeroTitleLine2Attribute);
|
||||
model.HeroFeatureLine1 = await _genericAttributeService.GetAttributeAsync<string>(product, NopProductPrDefaults.HeroFeatureLine1Attribute);
|
||||
model.HeroFeatureLine2 = await _genericAttributeService.GetAttributeAsync<string>(product, NopProductPrDefaults.HeroFeatureLine2Attribute);
|
||||
model.HeroPrCopy = await _genericAttributeService.GetAttributeAsync<string>(product, NopProductPrDefaults.HeroPrCopyAttribute);
|
||||
model.HeroTitleLine1FontSize = await _genericAttributeService.GetAttributeAsync<int?>(product, NopProductPrDefaults.HeroTitleLine1FontSizeAttribute);
|
||||
model.HeroTitleLine2FontSize = await _genericAttributeService.GetAttributeAsync<int?>(product, NopProductPrDefaults.HeroTitleLine2FontSizeAttribute);
|
||||
model.HeroFeatureLine1FontSize = await _genericAttributeService.GetAttributeAsync<int?>(product, NopProductPrDefaults.HeroFeatureLine1FontSizeAttribute);
|
||||
model.HeroFeatureLine2FontSize = await _genericAttributeService.GetAttributeAsync<int?>(product, NopProductPrDefaults.HeroFeatureLine2FontSizeAttribute);
|
||||
model.HeroPrCopyFontSize = await _genericAttributeService.GetAttributeAsync<int?>(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<string>(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine1Attribute, languageId));
|
||||
locale.HeroTitleLine2 = await _genericAttributeService.GetAttributeAsync<string>(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine2Attribute, languageId));
|
||||
locale.HeroFeatureLine1 = await _genericAttributeService.GetAttributeAsync<string>(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine1Attribute, languageId));
|
||||
locale.HeroFeatureLine2 = await _genericAttributeService.GetAttributeAsync<string>(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine2Attribute, languageId));
|
||||
locale.HeroPrCopy = await _genericAttributeService.GetAttributeAsync<string>(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroPrCopyAttribute, languageId));
|
||||
locale.HeroTitleLine1FontSize = await _genericAttributeService.GetAttributeAsync<int?>(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine1FontSizeAttribute, languageId));
|
||||
locale.HeroTitleLine2FontSize = await _genericAttributeService.GetAttributeAsync<int?>(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroTitleLine2FontSizeAttribute, languageId));
|
||||
locale.HeroFeatureLine1FontSize = await _genericAttributeService.GetAttributeAsync<int?>(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine1FontSizeAttribute, languageId));
|
||||
locale.HeroFeatureLine2FontSize = await _genericAttributeService.GetAttributeAsync<int?>(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroFeatureLine2FontSizeAttribute, languageId));
|
||||
locale.HeroPrCopyFontSize = await _genericAttributeService.GetAttributeAsync<int?>(product, NopProductPrDefaults.GetLocalizedKey(NopProductPrDefaults.HeroPrCopyFontSizeAttribute, languageId));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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<HomepageHeroProductModel>();
|
||||
}
|
||||
|
||||
public IList<HomepageHeroProductModel> Items { get; set; }
|
||||
public int AddProductId { get; set; }
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
//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; }
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
@model HomepageHeroProductListModel
|
||||
|
||||
@{
|
||||
ViewBag.PageTitle = "首頁輪播商品";
|
||||
NopHtml.SetActiveMenuItemSystemName("Products");
|
||||
}
|
||||
|
||||
<form asp-action="HomepageHeroUpdate" method="post">
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<div class="card card-default">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">新增輪播商品</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<label asp-for="AddProductId">商品 ID</label>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<nop-editor asp-for="AddProductId" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary" formaction="@Url.Action("HomepageHeroAdd", "Product")">加入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-default">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">輪播清單</h3>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-bordered table-striped mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:80px;">ID</th>
|
||||
<th>商品</th>
|
||||
<th style="width:120px;">啟用</th>
|
||||
<th style="width:140px;">順序</th>
|
||||
<th style="width:120px;">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (var i = 0; i < Model.Items.Count; i++)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@Model.Items[i].ProductId
|
||||
<input type="hidden" asp-for="@Model.Items[i].Id" />
|
||||
<input type="hidden" asp-for="@Model.Items[i].ProductId" />
|
||||
<input type="hidden" asp-for="@Model.Items[i].ProductName" />
|
||||
</td>
|
||||
<td>
|
||||
<a asp-controller="Product" asp-action="Edit" asp-route-id="@Model.Items[i].ProductId">@Model.Items[i].ProductName</a>
|
||||
</td>
|
||||
<td>
|
||||
<nop-editor asp-for="@Model.Items[i].IsEnabled" />
|
||||
</td>
|
||||
<td>
|
||||
<nop-editor asp-for="@Model.Items[i].DisplayOrder" />
|
||||
</td>
|
||||
<td>
|
||||
<button type="submit" class="btn btn-danger btn-sm" formaction="@Url.Action("HomepageHeroDelete", "Product", new { id = Model.Items[i].Id })">刪除</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<button type="submit" class="btn btn-primary">儲存排序/啟用狀態</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
@ -26,6 +26,10 @@
|
||||
<i class="fas fa-plus-square"></i>
|
||||
@T("Admin.Common.AddNew")
|
||||
</a>
|
||||
<a asp-action="HomepageHero" class="btn btn-info">
|
||||
<i class="fas fa-images"></i>
|
||||
首頁輪播
|
||||
</a>
|
||||
<button asp-action="DownloadCatalogPDF" type="submit" name="download-catalog-pdf" class="btn bg-purple">
|
||||
<i class="far fa-file-pdf"></i>
|
||||
@T("Admin.Catalog.Products.List.DownloadPDF")
|
||||
|
||||
@ -0,0 +1,101 @@
|
||||
@model ProductModel
|
||||
<div class="card-body">
|
||||
<div class="cards-group">
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3">
|
||||
<label for="@Html.IdFor(model => model.HeroEnabled)">啟用硬體 PR 區塊</label>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<nop-editor asp-for="HeroEnabled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@(await Html.LocalizedEditorAsync<ProductModel, ProductLocalizedModel>("product-hardware-pr-localized",
|
||||
@<div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.Locales[item].HeroTitleLine1)">左上標題(第一行)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="@Model.Locales[item].HeroTitleLine1" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.Locales[item].HeroTitleLine2)">左上標題(第二行)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="@Model.Locales[item].HeroTitleLine2" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.Locales[item].HeroFeatureLine1)">右上特性(第一行)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="@Model.Locales[item].HeroFeatureLine1" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.Locales[item].HeroFeatureLine2)">右上特性(第二行)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="@Model.Locales[item].HeroFeatureLine2" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.Locales[item].HeroPrCopy)">右下 PR 文案</label></div>
|
||||
<div class="col-md-9"><nop-textarea asp-for="@Model.Locales[item].HeroPrCopy"></nop-textarea></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.Locales[item].HeroTitleLine1FontSize)">左上第一行字級(px)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="@Model.Locales[item].HeroTitleLine1FontSize" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.Locales[item].HeroTitleLine2FontSize)">左上第二行字級(px)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="@Model.Locales[item].HeroTitleLine2FontSize" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.Locales[item].HeroFeatureLine1FontSize)">右上第一行字級(px)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="@Model.Locales[item].HeroFeatureLine1FontSize" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.Locales[item].HeroFeatureLine2FontSize)">右上第二行字級(px)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="@Model.Locales[item].HeroFeatureLine2FontSize" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.Locales[item].HeroPrCopyFontSize)">PR 文案字級(px)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="@Model.Locales[item].HeroPrCopyFontSize" /></div>
|
||||
</div>
|
||||
<input type="hidden" asp-for="@Model.Locales[item].LanguageId" />
|
||||
</div>,
|
||||
@<div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.HeroTitleLine1)">左上標題(第一行)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="HeroTitleLine1" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.HeroTitleLine2)">左上標題(第二行)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="HeroTitleLine2" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.HeroFeatureLine1)">右上特性(第一行)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="HeroFeatureLine1" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.HeroFeatureLine2)">右上特性(第二行)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="HeroFeatureLine2" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.HeroPrCopy)">右下 PR 文案</label></div>
|
||||
<div class="col-md-9"><nop-textarea asp-for="HeroPrCopy"></nop-textarea></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.HeroTitleLine1FontSize)">左上第一行字級(px)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="HeroTitleLine1FontSize" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.HeroTitleLine2FontSize)">左上第二行字級(px)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="HeroTitleLine2FontSize" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.HeroFeatureLine1FontSize)">右上第一行字級(px)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="HeroFeatureLine1FontSize" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.HeroFeatureLine2FontSize)">右上第二行字級(px)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="HeroFeatureLine2FontSize" /></div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<div class="col-md-3"><label for="@Html.IdFor(model => model.HeroPrCopyFontSize)">PR 文案字級(px)</label></div>
|
||||
<div class="col-md-9"><nop-editor asp-for="HeroPrCopyFontSize" /></div>
|
||||
</div>
|
||||
</div>))
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -80,6 +80,7 @@
|
||||
<nop-card asp-name="product-shipping" asp-icon="fas fa-truck" asp-title="@T("Admin.Catalog.Products.Shipping")" asp-hide-block-attribute-name="@hideShippingBlockAttributeName" asp-hide="@hideShippingBlock" asp-advanced="false">@await Html.PartialAsync("_CreateOrUpdate.Shipping", Model)</nop-card>
|
||||
<nop-card asp-name="product-inventory" asp-icon="fas fa-sitemap" asp-title="@T("Admin.Catalog.Products.Inventory")" asp-hide-block-attribute-name="@hideInventoryBlockAttributeName" asp-hide="@hideInventoryBlock" asp-advanced="false">@await Html.PartialAsync("_CreateOrUpdate.Inventory", Model)</nop-card>
|
||||
<nop-card asp-name="product-multimedia" asp-icon="fas fa-photo-video" asp-title="@T("Admin.Catalog.Products.Multimedia")" asp-hide-block-attribute-name="@hideMultimediaBlockAttributeName" asp-hide="@hideMultimediaBlock" asp-advanced="false">@await Html.PartialAsync("_CreateOrUpdate.Multimedia", Model)</nop-card>
|
||||
<nop-card asp-name="product-hardware-pr" asp-icon="fas fa-bullhorn" asp-title="硬體 PR 區塊" asp-hide-block-attribute-name="ProductPage.HideHardwarePrBlock" asp-hide="false" asp-advanced="false">@await Html.PartialAsync("_CreateOrUpdate.HardwarePr", Model)</nop-card>
|
||||
<nop-card asp-name="product-product-attributes" asp-icon="fas fa-paperclip" asp-title="@T("Admin.Catalog.Products.ProductAttributes")" asp-hide-block-attribute-name="@hideProductAttributesBlockAttributeName" asp-hide="@hideProductAttributesBlock" asp-advanced="@(!Model.ProductEditorSettingsModel.ProductAttributes)">@await Html.PartialAsync("_CreateOrUpdate.ProductAttributes", Model)</nop-card>
|
||||
<nop-card asp-name="product-specification-attributes" asp-icon="fas fa-cog" asp-title="@T("Admin.Catalog.Attributes.SpecificationAttributes")" asp-hide-block-attribute-name="@hideSpecificationAttributeBlockAttributeName" asp-hide="@hideSpecificationAttributeBlock" asp-advanced="@(!Model.ProductEditorSettingsModel.SpecificationAttributes)">@await Html.PartialAsync("_CreateOrUpdate.SpecificationAttributes", Model)</nop-card>
|
||||
<nop-card asp-name="product-giftcard" asp-icon="fas fa-gift" asp-title="@T("Admin.Catalog.Products.GiftCard")" asp-hide-block-attribute-name="@hideGiftCardBlockAttributeName" asp-hide="@hideGiftCardBlock" asp-advanced="@(!Model.ProductEditorSettingsModel.IsGiftCard)">@await Html.PartialAsync("_CreateOrUpdate.GiftCard", Model)</nop-card>
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Nop.Core;
|
||||
using Nop.Services.Security;
|
||||
using Nop.Services.Stores;
|
||||
using Nop.Core.Domain.Catalog;
|
||||
using Nop.Services.Catalog;
|
||||
using Nop.Web.Factories;
|
||||
using Nop.Web.Framework.Components;
|
||||
|
||||
namespace Nop.Web.Components;
|
||||
|
||||
public partial class HomepageHeroProductsViewComponent : NopViewComponent
|
||||
{
|
||||
protected readonly IHomepageHeroProductService _homepageHeroProductService;
|
||||
protected readonly IAclService _aclService;
|
||||
protected readonly IProductService _productService;
|
||||
protected readonly IProductModelFactory _productModelFactory;
|
||||
protected readonly IStoreMappingService _storeMappingService;
|
||||
protected readonly IStoreContext _storeContext;
|
||||
|
||||
public HomepageHeroProductsViewComponent(
|
||||
IHomepageHeroProductService homepageHeroProductService,
|
||||
IAclService aclService,
|
||||
IProductService productService,
|
||||
IProductModelFactory productModelFactory,
|
||||
IStoreMappingService storeMappingService,
|
||||
IStoreContext storeContext)
|
||||
{
|
||||
_homepageHeroProductService = homepageHeroProductService;
|
||||
_aclService = aclService;
|
||||
_productService = productService;
|
||||
_productModelFactory = productModelFactory;
|
||||
_storeMappingService = storeMappingService;
|
||||
_storeContext = storeContext;
|
||||
}
|
||||
|
||||
public virtual async Task<IViewComponentResult> InvokeAsync()
|
||||
{
|
||||
var mappings = await _homepageHeroProductService.GetAllAsync(onlyEnabled: true);
|
||||
var models = new List<Models.Catalog.ProductDetailsModel>();
|
||||
|
||||
foreach (var mapping in mappings.OrderBy(x => x.DisplayOrder).ThenBy(x => x.Id))
|
||||
{
|
||||
var product = await _productService.GetProductByIdAsync(mapping.ProductId);
|
||||
if (product == null || product.Deleted || !product.Published)
|
||||
continue;
|
||||
if (!await _aclService.AuthorizeAsync(product) || !await _storeMappingService.AuthorizeAsync(product))
|
||||
continue;
|
||||
if (!_productService.ProductIsAvailable(product))
|
||||
continue;
|
||||
|
||||
var detailsModel = await _productModelFactory.PrepareProductDetailsModelAsync(product);
|
||||
if (!detailsModel.HeroEnabled)
|
||||
continue;
|
||||
|
||||
models.Add(detailsModel);
|
||||
}
|
||||
|
||||
// fallback: if no explicit homepage hero mapping, use products that have HeroEnabled.
|
||||
if (!models.Any())
|
||||
{
|
||||
var store = await _storeContext.GetCurrentStoreAsync();
|
||||
var candidates = await _productService.SearchProductsAsync(
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
storeId: store.Id,
|
||||
visibleIndividuallyOnly: true,
|
||||
showHidden: false,
|
||||
orderBy: ProductSortingEnum.Position);
|
||||
|
||||
foreach (var product in candidates)
|
||||
{
|
||||
if (product == null || product.Deleted || !product.Published)
|
||||
continue;
|
||||
if (!await _aclService.AuthorizeAsync(product) || !await _storeMappingService.AuthorizeAsync(product))
|
||||
continue;
|
||||
if (!_productService.ProductIsAvailable(product))
|
||||
continue;
|
||||
|
||||
var detailsModel = await _productModelFactory.PrepareProductDetailsModelAsync(product);
|
||||
if (!detailsModel.HeroEnabled)
|
||||
continue;
|
||||
|
||||
models.Add(detailsModel);
|
||||
if (models.Count >= 4)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return View(models);
|
||||
}
|
||||
}
|
||||
@ -1379,6 +1379,9 @@ public partial class ProductModelFactory : IProductModelFactory
|
||||
SeName = await _urlRecordService.GetSeNameAsync(product),
|
||||
Sku = product.Sku,
|
||||
ProductType = product.ProductType,
|
||||
StockQuantity = product.StockQuantity,
|
||||
MinStockQuantity = product.MinStockQuantity,
|
||||
NotifyAdminForQuantityBelow = product.NotifyAdminForQuantityBelow,
|
||||
MarkAsNew = product.MarkAsNew &&
|
||||
(!product.MarkAsNewStartDateTimeUtc.HasValue || product.MarkAsNewStartDateTimeUtc.Value < DateTime.UtcNow) &&
|
||||
(!product.MarkAsNewEndDateTimeUtc.HasValue || product.MarkAsNewEndDateTimeUtc.Value > DateTime.UtcNow)
|
||||
@ -1469,6 +1472,24 @@ public partial class ProductModelFactory : IProductModelFactory
|
||||
return result;
|
||||
}
|
||||
|
||||
protected virtual async Task<string> GetLocalizedHeroValueAsync(Product product, string key, int languageId)
|
||||
{
|
||||
var localized = await _genericAttributeService.GetAttributeAsync<string>(product, NopProductPrDefaults.GetLocalizedKey(key, languageId));
|
||||
if (!string.IsNullOrWhiteSpace(localized))
|
||||
return localized;
|
||||
|
||||
return await _genericAttributeService.GetAttributeAsync<string>(product, key);
|
||||
}
|
||||
|
||||
protected virtual async Task<int?> GetLocalizedHeroFontSizeAsync(Product product, string key, int languageId)
|
||||
{
|
||||
var localized = await _genericAttributeService.GetAttributeAsync<int?>(product, NopProductPrDefaults.GetLocalizedKey(key, languageId));
|
||||
if (localized.HasValue)
|
||||
return localized;
|
||||
|
||||
return await _genericAttributeService.GetAttributeAsync<int?>(product, key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepare the product details model
|
||||
/// </summary>
|
||||
@ -1724,6 +1745,19 @@ public partial class ProductModelFactory : IProductModelFactory
|
||||
model.JsonLd = JsonConvert.SerializeObject(jsonLdModel, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
|
||||
}
|
||||
|
||||
var languageId = (await _workContext.GetWorkingLanguageAsync()).Id;
|
||||
model.HeroEnabled = await _genericAttributeService.GetAttributeAsync<bool>(product, NopProductPrDefaults.HeroEnabledAttribute);
|
||||
model.HeroTitleLine1 = await GetLocalizedHeroValueAsync(product, NopProductPrDefaults.HeroTitleLine1Attribute, languageId);
|
||||
model.HeroTitleLine2 = await GetLocalizedHeroValueAsync(product, NopProductPrDefaults.HeroTitleLine2Attribute, languageId);
|
||||
model.HeroFeatureLine1 = await GetLocalizedHeroValueAsync(product, NopProductPrDefaults.HeroFeatureLine1Attribute, languageId);
|
||||
model.HeroFeatureLine2 = await GetLocalizedHeroValueAsync(product, NopProductPrDefaults.HeroFeatureLine2Attribute, languageId);
|
||||
model.HeroPrCopy = await GetLocalizedHeroValueAsync(product, NopProductPrDefaults.HeroPrCopyAttribute, languageId);
|
||||
model.HeroTitleLine1FontSize = await GetLocalizedHeroFontSizeAsync(product, NopProductPrDefaults.HeroTitleLine1FontSizeAttribute, languageId);
|
||||
model.HeroTitleLine2FontSize = await GetLocalizedHeroFontSizeAsync(product, NopProductPrDefaults.HeroTitleLine2FontSizeAttribute, languageId);
|
||||
model.HeroFeatureLine1FontSize = await GetLocalizedHeroFontSizeAsync(product, NopProductPrDefaults.HeroFeatureLine1FontSizeAttribute, languageId);
|
||||
model.HeroFeatureLine2FontSize = await GetLocalizedHeroFontSizeAsync(product, NopProductPrDefaults.HeroFeatureLine2FontSizeAttribute, languageId);
|
||||
model.HeroPrCopyFontSize = await GetLocalizedHeroFontSizeAsync(product, NopProductPrDefaults.HeroPrCopyFontSizeAttribute, languageId);
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
|
||||
@ -125,6 +125,19 @@ public partial record ProductDetailsModel : BaseNopEntityModel
|
||||
|
||||
public bool AllowAddingOnlyExistingAttributeCombinations { 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; }
|
||||
public int? HeroTitleLine1FontSize { get; set; }
|
||||
public int? HeroTitleLine2FontSize { get; set; }
|
||||
public int? HeroFeatureLine1FontSize { get; set; }
|
||||
public int? HeroFeatureLine2FontSize { get; set; }
|
||||
public int? HeroPrCopyFontSize { get; set; }
|
||||
|
||||
#region Nested Classes
|
||||
|
||||
public partial record ProductBreadcrumbModel : BaseNopModel
|
||||
|
||||
@ -27,6 +27,9 @@ public partial record ProductOverviewModel : BaseNopEntityModel
|
||||
public ProductType ProductType { get; set; }
|
||||
|
||||
public bool MarkAsNew { get; set; }
|
||||
public int StockQuantity { get; set; }
|
||||
public int MinStockQuantity { get; set; }
|
||||
public int NotifyAdminForQuantityBelow { get; set; }
|
||||
|
||||
//price
|
||||
public ProductPriceModel ProductPrice { get; set; }
|
||||
|
||||
@ -0,0 +1,555 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Alata&display=swap');
|
||||
|
||||
.html-home-page .header,
|
||||
.html-home-page .header-menu,
|
||||
.html-home-page .footer,
|
||||
.html-home-page .side-2 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-page .master-wrapper-content {
|
||||
width: 1280px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.kneo-home {
|
||||
width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 0 40px;
|
||||
}
|
||||
|
||||
.kneo-home-header,
|
||||
.kneo-home-header * {
|
||||
font-family: "Alata", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.kneo-home-header {
|
||||
position: relative;
|
||||
height: 96px;
|
||||
background: linear-gradient(90deg, #0c3b84 0%, #2f97d4 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 56px 0 54px;
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.kneo-home-header::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
box-shadow: 6px 6px 12px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kneo-home-header__brand {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 36px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: #07111a;
|
||||
letter-spacing: 0.8px;
|
||||
}
|
||||
|
||||
.kneo-home-header__nav {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 42px;
|
||||
}
|
||||
|
||||
.kneo-home-header__nav a {
|
||||
color: #07111a;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kneo-home__section {
|
||||
}
|
||||
|
||||
.kneo-home__section--hero {
|
||||
position: relative;
|
||||
margin-bottom: 0;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
padding: 20px 20px 0 20px;
|
||||
}
|
||||
|
||||
.kneo-home__section--hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 55%;
|
||||
background: linear-gradient(90deg, #0c3b84 0%, #2f97d4 100%);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.kneo-home__section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 4px 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.kneo-home__section-head h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.kneo-home__section-head h2::before,
|
||||
.kneo-home__section-head h2::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 220px;
|
||||
height: 10px;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.kneo-home__section-head h2::before {
|
||||
background:
|
||||
linear-gradient(currentColor, currentColor) left center / 200px 1px no-repeat,
|
||||
radial-gradient(circle at center, currentColor 0 5px, transparent 5px) right center / 10px 10px no-repeat;
|
||||
}
|
||||
|
||||
.kneo-home__section-head h2::after {
|
||||
background:
|
||||
radial-gradient(circle at center, currentColor 0 5px, transparent 5px) left center / 10px 10px no-repeat,
|
||||
linear-gradient(currentColor, currentColor) right center / 200px 1px no-repeat;
|
||||
}
|
||||
|
||||
.kneo-home__section-head a,
|
||||
.kneo-home__section-tail a {
|
||||
color: #2f4150;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.homepage-hero-products {
|
||||
background-image: url('/Themes/DefaultClean/Kneo/Content/images/hero/hero-background.png');
|
||||
background-repeat: no-repeat;
|
||||
background-size: 955px 632px;
|
||||
min-height: 600px;
|
||||
padding: 0 40px 30px;
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.homepage-hero-products::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.homepage-hero-products > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.homepage-hero-products--empty {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.homepage-hero-products__empty-title {
|
||||
font-size: 92px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.homepage-hero-products__empty-subtitle {
|
||||
font-size: 72px;
|
||||
line-height: 1.1;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.homepage-hero-products__empty-copy {
|
||||
font-size: 28px;
|
||||
line-height: 1.4;
|
||||
margin-top: 26px;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.homepage-hero-products__dots {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.homepage-hero-products__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.homepage-hero-products__dot.is-active {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.kneo-home__section--hardware {
|
||||
background: url('/Themes/DefaultClean/Kneo/Content/images/home/hw-back-band.png') no-repeat top center;
|
||||
background-size: 1228px 377px;
|
||||
min-height: 300px;
|
||||
padding: 44px 48px 24px 70px;
|
||||
}
|
||||
|
||||
.kneo-home__section--book {
|
||||
background: url('/Themes/DefaultClean/Kneo/Content/images/home/book-back-band.png') no-repeat top center;
|
||||
background-size: 1190px 360px;
|
||||
min-height: 360px;
|
||||
padding: 24px 48px 24px 70px;
|
||||
}
|
||||
|
||||
.kneo-home__section--application {
|
||||
background: url('/Themes/DefaultClean/Kneo/Content/images/home/app-back-band.png') no-repeat top center;
|
||||
background-size: 1200px 328px;
|
||||
min-height: 328px;
|
||||
padding: 24px 48px 24px 70px;
|
||||
}
|
||||
|
||||
.kneo-home__hardware-body .product-grid {
|
||||
}
|
||||
|
||||
.kneo-home__hardware-body .item-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.kneo-home-hw-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 256px;
|
||||
height: 256px;
|
||||
background: url('/Themes/DefaultClean/Kneo/Content/images/common/hw-product-back.png') no-repeat center top;
|
||||
background-size: 100% 100%;
|
||||
padding: 24px 30px;
|
||||
}
|
||||
|
||||
.kneo-home-hw-card:hover {
|
||||
background-image: url('/Themes/DefaultClean/Kneo/Content/images/common/hw-product-back-hover.png');
|
||||
}
|
||||
|
||||
.kneo-home-hw-card__hit {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.kneo-home-hw-card__stock-icon {
|
||||
width: 81px;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
margin-left: auto;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.kneo-home-hw-card.in-stock .kneo-home-hw-card__stock-icon {
|
||||
background-image: url('/Themes/DefaultClean/Kneo/Content/images/common/in-stock.png');
|
||||
}
|
||||
|
||||
.kneo-home-hw-card.low-stock .kneo-home-hw-card__stock-icon {
|
||||
width: 98px;
|
||||
background-image: url('/Themes/DefaultClean/Kneo/Content/images/common/low-stock.png');
|
||||
}
|
||||
|
||||
.kneo-home-hw-card.out-stock .kneo-home-hw-card__stock-icon {
|
||||
background-image: url('/Themes/DefaultClean/Kneo/Content/images/common/out-stock.png');
|
||||
}
|
||||
|
||||
.kneo-home-hw-card.in-stock:hover .kneo-home-hw-card__stock-icon {
|
||||
background-image: url('/Themes/DefaultClean/Kneo/Content/images/common/in-stock-hover.png');
|
||||
}
|
||||
|
||||
.kneo-home-hw-card.low-stock:hover .kneo-home-hw-card__stock-icon {
|
||||
width: 98px;
|
||||
background-image: url('/Themes/DefaultClean/Kneo/Content/images/common/low-stock-hover.png');
|
||||
}
|
||||
|
||||
.kneo-home-hw-card.out-stock:hover .kneo-home-hw-card__stock-icon {
|
||||
background-image: url('/Themes/DefaultClean/Kneo/Content/images/common/out-stock-hover.png');
|
||||
}
|
||||
|
||||
.kneo-home-hw-card__picture {
|
||||
height: 172px;
|
||||
width: 194px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.kneo-home-hw-card__picture img {
|
||||
max-width: 172px;
|
||||
max-height: 172px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.kneo-home-hw-card__text {
|
||||
color: #ffffff;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
column-gap: 6px;
|
||||
row-gap: 2px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.kneo-home-hw-card:hover .kneo-home-hw-card__text {
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.kneo-home-hw-card__price {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
align-self: start;
|
||||
letter-spacing: 2.4px;
|
||||
}
|
||||
|
||||
.kneo-home-hw-card__tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
width: fit-content;
|
||||
min-width: 81px;
|
||||
height: 25px;
|
||||
padding: 2px 6px;
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
align-self: end;
|
||||
letter-spacing: 2.4px;
|
||||
}
|
||||
|
||||
.kneo-home-hw-card__name {
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.2;
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
align-self: end;
|
||||
max-height: calc(1.2em * 2);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
margin-bottom: 2px;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.kneo-home-hw-card__name a {
|
||||
}
|
||||
.kneo-home__hardware-body .product-grid {
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.kneo-home__section-tail {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: 108px;
|
||||
}
|
||||
|
||||
.kneo-home__tail-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kneo-home__tail-link span {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.kneo-home__section--book .kneo-home__section-head h2 {
|
||||
color: #00a4d7;
|
||||
}
|
||||
|
||||
.kneo-home__book-body {
|
||||
height: 286px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.kneo-home__placeholder {
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: #5f6e7a;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.kneo-home__section--application {
|
||||
margin-top: -36px;
|
||||
}
|
||||
|
||||
.kneo-home__section--application .kneo-home__section-head h2 {
|
||||
color: #16404f;
|
||||
}
|
||||
|
||||
.kneo-home__apps {
|
||||
width: 1140px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
padding-left: 12px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.kneo-home__app {
|
||||
position: relative;
|
||||
width: 170px;
|
||||
height: 184px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.kneo-home__app-img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 170px;
|
||||
height: 184px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.kneo-home__app-img--default {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.kneo-home__app-img--hover {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.kneo-home__app:hover .kneo-home__app-img--default {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.kneo-home__app:hover .kneo-home__app-img--hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.kneo-home__section--footer {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__main {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 26px;
|
||||
row-gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__title {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
color: #ffffff;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__line1 {
|
||||
font-size: 96px;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__line2 {
|
||||
font-size: 82px;
|
||||
line-height: 1.06;
|
||||
margin-top: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__picture {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-top: 28px;
|
||||
margin-left: -22px;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__picture img {
|
||||
max-width: 108%;
|
||||
max-height: 370px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__feature {
|
||||
position: absolute;
|
||||
right: 78px;
|
||||
top: 40px;
|
||||
background: url('/Themes/DefaultClean/Kneo/Content/images/hero/hero-effecient.png') no-repeat center;
|
||||
background-size: cover;
|
||||
border-radius: 20px;
|
||||
width: 292px;
|
||||
min-height: 275px;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__feature-line1 {
|
||||
font-size: 82px;
|
||||
line-height: 0.95;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__feature-line2 {
|
||||
font-size: 58px;
|
||||
line-height: 1.05;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__copy {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 364px;
|
||||
font-size: 24px;
|
||||
line-height: 2;
|
||||
letter-spacing: 2.5px;
|
||||
color: #183041;
|
||||
padding: 0;
|
||||
width: 243px;
|
||||
word-break: break-word;
|
||||
writing-mode: horizontal-tb;
|
||||
text-orientation: mixed;
|
||||
}
|
||||
|
||||
.home-page .kneo-product-hero-pr__copy-more summary {
|
||||
cursor: pointer;
|
||||
color: #0f5e9a;
|
||||
}
|
||||
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
@ -12,6 +12,7 @@
|
||||
|
||||
//add main CSS file
|
||||
NopHtml.AppendCssFileParts($"~/Themes/{themeName}/Content/css/styles{(supportRtl ? ".rtl" : "")}.css");
|
||||
NopHtml.AppendCssFileParts($"~/Themes/{themeName}/Kneo/Content/css/home.css");
|
||||
|
||||
//add swiper css file
|
||||
if (catalogSettings.DisplayAllPicturesOnCatalogPages)
|
||||
|
||||
@ -28,18 +28,119 @@
|
||||
}
|
||||
<div class="page home-page">
|
||||
<div class="page-body">
|
||||
@await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.HomepageTop })
|
||||
@await Component.InvokeAsync(typeof(TopicBlockViewComponent), new { systemName = "HomepageText" })
|
||||
@await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.HomepageBeforeCategories })
|
||||
@await Component.InvokeAsync(typeof(HomepageCategoriesViewComponent))
|
||||
@await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.HomepageBeforeProducts })
|
||||
@await Component.InvokeAsync(typeof(HomepageProductsViewComponent))
|
||||
@await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.HomepageBeforeBestSellers })
|
||||
@await Component.InvokeAsync(typeof(HomepageBestSellersViewComponent))
|
||||
@await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.HomepageBeforeNews })
|
||||
@await Component.InvokeAsync(typeof(HomepageNewsViewComponent))
|
||||
@await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.HomepageBeforePoll })
|
||||
@await Component.InvokeAsync(typeof(HomepagePollsViewComponent))
|
||||
<section class="kneo-home kneo-home--1280">
|
||||
<div class="kneo-home-header">
|
||||
<div class="kneo-home-header__brand">KNEO PLATFORM</div>
|
||||
<nav class="kneo-home-header__nav">
|
||||
<a href="@Url.RouteUrl("Category", new { SeName = "hardware" })">硬體</a>
|
||||
<a href="@Url.RouteUrl("Category", new { SeName = "software" })">軟體</a>
|
||||
<a href="@Url.RouteUrl("Category", new { SeName = "ai-application" })">AI應用範圍</a>
|
||||
<a href="@Url.RouteUrl("Category", new { SeName = "books" })">書城</a>
|
||||
<a href="@Url.RouteUrl("KneronLogin")">會員登入</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="kneo-home__section kneo-home__section--hero">
|
||||
@await Component.InvokeAsync(typeof(HomepageHeroProductsViewComponent))
|
||||
</div>
|
||||
|
||||
<div class="kneo-home__section kneo-home__section--hardware">
|
||||
<div class="kneo-home__hardware-body">
|
||||
@await Component.InvokeAsync(typeof(HomepageProductsViewComponent))
|
||||
</div>
|
||||
<div class="kneo-home__section-tail">
|
||||
<a class="kneo-home__tail-link" href="/hardware">
|
||||
<svg width="9" height="11" viewBox="0 0 9 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_7_3)">
|
||||
<path d="M0.10614 10.8135L8.60081 5.32684L0.10614 0.186401" stroke="#1A1A1A" stroke-width="0.8" stroke-miterlimit="10"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_7_3">
|
||||
<rect width="9" height="11" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>SEE ALL SUPPLIMENTS</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kneo-home__section kneo-home__section--book">
|
||||
<div class="kneo-home__section-head">
|
||||
<h2>推薦書籍</h2>
|
||||
</div>
|
||||
<div class="kneo-home__book-body">
|
||||
<div class="kneo-home__placeholder">書城輪播區(待接書籍首頁資料)</div>
|
||||
</div>
|
||||
<div class="kneo-home__section-tail">
|
||||
<a class="kneo-home__tail-link" href="/books">
|
||||
<svg width="9" height="11" viewBox="0 0 9 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_7_3)">
|
||||
<path d="M0.10614 10.8135L8.60081 5.32684L0.10614 0.186401" stroke="#1A1A1A" stroke-width="0.8" stroke-miterlimit="10"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_7_3">
|
||||
<rect width="9" height="11" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>SEE ALL BOOKS</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kneo-home__section kneo-home__section--application">
|
||||
<div class="kneo-home__section-head">
|
||||
<h2>應用領域</h2>
|
||||
</div>
|
||||
<div class="kneo-home__apps">
|
||||
<a class="kneo-home__app" href="#" aria-label="Smart Retail">
|
||||
<img class="kneo-home__app-img kneo-home__app-img--default" src="~/Themes/DefaultClean/Kneo/Content/images/home/smart-retail.png" alt="Smart Retail" />
|
||||
<img class="kneo-home__app-img kneo-home__app-img--hover" src="~/Themes/DefaultClean/Kneo/Content/images/home/smart-retail-hover.png" alt="" aria-hidden="true" />
|
||||
</a>
|
||||
<a class="kneo-home__app" href="#" aria-label="Medical & Health">
|
||||
<img class="kneo-home__app-img kneo-home__app-img--default" src="~/Themes/DefaultClean/Kneo/Content/images/home/medical-health.png" alt="Medical & Health" />
|
||||
<img class="kneo-home__app-img kneo-home__app-img--hover" src="~/Themes/DefaultClean/Kneo/Content/images/home/medical-health-hover.png" alt="" aria-hidden="true" />
|
||||
</a>
|
||||
<a class="kneo-home__app" href="#" aria-label="Vehicle & Transportation">
|
||||
<img class="kneo-home__app-img kneo-home__app-img--default" src="~/Themes/DefaultClean/Kneo/Content/images/home/vehicle-transportation.png" alt="Vehicle & Transportation" />
|
||||
<img class="kneo-home__app-img kneo-home__app-img--hover" src="~/Themes/DefaultClean/Kneo/Content/images/home/vehicle-transportation-hover.png" alt="" aria-hidden="true" />
|
||||
</a>
|
||||
<a class="kneo-home__app" href="#" aria-label="Smart Manufacture">
|
||||
<img class="kneo-home__app-img kneo-home__app-img--default" src="~/Themes/DefaultClean/Kneo/Content/images/home/smart-manufacture.png" alt="Smart Manufacture" />
|
||||
<img class="kneo-home__app-img kneo-home__app-img--hover" src="~/Themes/DefaultClean/Kneo/Content/images/home/smart-manufacture-hover.png" alt="" aria-hidden="true" />
|
||||
</a>
|
||||
<a class="kneo-home__app" href="#" aria-label="Smart Home">
|
||||
<img class="kneo-home__app-img kneo-home__app-img--default" src="~/Themes/DefaultClean/Kneo/Content/images/home/smart-home.png" alt="Smart Home" />
|
||||
<img class="kneo-home__app-img kneo-home__app-img--hover" src="~/Themes/DefaultClean/Kneo/Content/images/home/smart-home-hover.png" alt="" aria-hidden="true" />
|
||||
</a>
|
||||
<a class="kneo-home__app" href="#" aria-label="Future City">
|
||||
<img class="kneo-home__app-img kneo-home__app-img--default" src="~/Themes/DefaultClean/Kneo/Content/images/home/future-city.png" alt="Future City" />
|
||||
<img class="kneo-home__app-img kneo-home__app-img--hover" src="~/Themes/DefaultClean/Kneo/Content/images/home/future-city-hover.png" alt="" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="kneo-home__section-tail">
|
||||
<a class="kneo-home__tail-link" href="/ai-application">
|
||||
<svg width="9" height="11" viewBox="0 0 9 11" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_7_3)">
|
||||
<path d="M0.10614 10.8135L8.60081 5.32684L0.10614 0.186401" stroke="#1A1A1A" stroke-width="0.8" stroke-miterlimit="10"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_7_3">
|
||||
<rect width="9" height="11" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<span>SEE ALL APPLICATION</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kneo-home__section kneo-home__section--footer">
|
||||
<div class="kneo-home__placeholder">Footer 區塊(待設計)</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.HomepageBottom })
|
||||
</div>
|
||||
</div>
|
||||
@ -67,6 +67,7 @@
|
||||
</script>
|
||||
}
|
||||
<div data-productid="@Model.Id">
|
||||
@await Html.PartialAsync("_ProductHardwarePrHero", Model)
|
||||
<div class="product-essential">
|
||||
@await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.ProductDetailsEssentialTop, additionalData = Model })
|
||||
<div class="gallery">
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
@model ProductDetailsModel
|
||||
@{
|
||||
var hasHeroContent = !string.IsNullOrWhiteSpace(Model.HeroTitleLine1)
|
||||
|| !string.IsNullOrWhiteSpace(Model.HeroTitleLine2)
|
||||
|| !string.IsNullOrWhiteSpace(Model.HeroFeatureLine1)
|
||||
|| !string.IsNullOrWhiteSpace(Model.HeroFeatureLine2)
|
||||
|| !string.IsNullOrWhiteSpace(Model.HeroPrCopy);
|
||||
var canShow = Model.HeroEnabled && hasHeroContent;
|
||||
}
|
||||
|
||||
@if (canShow)
|
||||
{
|
||||
<section class="kneo-product-hero-pr">
|
||||
<div class="kneo-product-hero-pr__main">
|
||||
<div class="kneo-product-hero-pr__title">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.HeroTitleLine1))
|
||||
{
|
||||
<div class="kneo-product-hero-pr__line1" style="@(Model.HeroTitleLine1FontSize.HasValue ? $"font-size:{Model.HeroTitleLine1FontSize.Value}px;" : null)">@Model.HeroTitleLine1</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.HeroTitleLine2))
|
||||
{
|
||||
<div class="kneo-product-hero-pr__line2" style="@(Model.HeroTitleLine2FontSize.HasValue ? $"font-size:{Model.HeroTitleLine2FontSize.Value}px;" : null)">@Model.HeroTitleLine2</div>
|
||||
}
|
||||
</div>
|
||||
<div class="kneo-product-hero-pr__picture">
|
||||
<img src="@Model.DefaultPictureModel.ImageUrl" alt="@Model.DefaultPictureModel.AlternateText" />
|
||||
</div>
|
||||
<div class="kneo-product-hero-pr__feature">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.HeroFeatureLine1))
|
||||
{
|
||||
<div class="kneo-product-hero-pr__feature-line1" style="@(Model.HeroFeatureLine1FontSize.HasValue ? $"font-size:{Model.HeroFeatureLine1FontSize.Value}px;" : null)">@Model.HeroFeatureLine1</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.HeroFeatureLine2))
|
||||
{
|
||||
<div class="kneo-product-hero-pr__feature-line2" style="@(Model.HeroFeatureLine2FontSize.HasValue ? $"font-size:{Model.HeroFeatureLine2FontSize.Value}px;" : null)">@Model.HeroFeatureLine2</div>
|
||||
}
|
||||
</div>
|
||||
<div class="kneo-product-hero-pr__copy" style="@(Model.HeroPrCopyFontSize.HasValue ? $"font-size:{Model.HeroPrCopyFontSize.Value}px;" : null)">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.HeroPrCopy))
|
||||
{
|
||||
var longCopy = Model.HeroPrCopy.Length > 120;
|
||||
if (longCopy)
|
||||
{
|
||||
<div class="kneo-product-hero-pr__copy-preview">@Model.HeroPrCopy.Substring(0, 120)...</div>
|
||||
<details class="kneo-product-hero-pr__copy-more">
|
||||
<summary>顯示全文</summary>
|
||||
<div>@Model.HeroPrCopy</div>
|
||||
</details>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div>@Model.HeroPrCopy</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
@model IList<ProductDetailsModel>
|
||||
|
||||
@if (Model?.Any() == true)
|
||||
{
|
||||
var first = Model.First();
|
||||
<div class="homepage-hero-products">
|
||||
<div class="homepage-hero-products__current">
|
||||
@await Html.PartialAsync("~/Views/Product/_ProductHardwarePrHero.cshtml", first)
|
||||
</div>
|
||||
@if (Model.Count > 1)
|
||||
{
|
||||
<div class="homepage-hero-products__dots">
|
||||
@for (var i = 0; i < Model.Count; i++)
|
||||
{
|
||||
<span class="homepage-hero-products__dot @(i == 0 ? "is-active" : null)"></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="homepage-hero-products homepage-hero-products--empty">
|
||||
<div class="homepage-hero-products__empty-title">AI Camera</div>
|
||||
<div class="homepage-hero-products__empty-subtitle">智慧影像識別設備</div>
|
||||
<div class="homepage-hero-products__empty-copy">首頁 Hero 尚未設定產品,請至後台「首頁輪播」選擇商品。</div>
|
||||
</div>
|
||||
}
|
||||
@ -1,16 +1,39 @@
|
||||
@model IList<ProductOverviewModel>
|
||||
@using System.Linq
|
||||
@using Nop.Core.Domain.Catalog
|
||||
@if (Model.Count > 0)
|
||||
{
|
||||
<div class="product-grid home-page-product-grid">
|
||||
<div class="title">
|
||||
<strong>@T("Homepage.Products")</strong>
|
||||
</div>
|
||||
var homeItems = Model.Take(4).ToList();
|
||||
<div class="product-grid home-page-product-grid kneo-home-hw-grid">
|
||||
<div class="item-grid">
|
||||
@foreach (var item in Model)
|
||||
@foreach (var item in homeItems)
|
||||
{
|
||||
<div class="item-box">
|
||||
@await Html.PartialAsync("_ProductBox", item)
|
||||
</div>
|
||||
var statusClass = "in-stock";
|
||||
if (item.StockQuantity <= 0)
|
||||
{
|
||||
statusClass = "out-stock";
|
||||
}
|
||||
else if (item.NotifyAdminForQuantityBelow > 0 && item.StockQuantity <= item.NotifyAdminForQuantityBelow)
|
||||
{
|
||||
statusClass = "low-stock";
|
||||
}
|
||||
var productUrl = Url.RouteUrl<Product>(new { SeName = item.SeName });
|
||||
var picture = item.PictureModels.FirstOrDefault();
|
||||
<article class="kneo-home-hw-card @statusClass">
|
||||
<a class="kneo-home-hw-card__hit" href="@productUrl" aria-label="@item.Name"></a>
|
||||
<div class="kneo-home-hw-card__stock-icon"></div>
|
||||
<div class="kneo-home-hw-card__picture">
|
||||
@if (picture != null)
|
||||
{
|
||||
<img src="@picture.ImageUrl" alt="@picture.AlternateText" />
|
||||
}
|
||||
</div>
|
||||
<div class="kneo-home-hw-card__text">
|
||||
<div class="kneo-home-hw-card__price">@item.ProductPrice.Price</div>
|
||||
<div class="kneo-home-hw-card__tag">加速硬體</div>
|
||||
<div class="kneo-home-hw-card__name"><a href="@productUrl">@item.Name</a></div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||