Add variant collections system and enhance ProductVariant with weight/stock tracking
This commit introduces a comprehensive variant management system and enhances the existing ProductVariant model with per-variant weight overrides and stock tracking, integrated across Admin Panel and TeleBot. Features Added: - Variant Collections: Reusable variant templates (e.g., "Standard Sizes") - Admin UI for managing variant collections (CRUD operations) - Dynamic variant editor with JavaScript-based UI - Per-variant weight and weight unit overrides - Per-variant stock level tracking - SalesLedger model for financial tracking ProductVariant Enhancements: - Added Weight (decimal, nullable) field for variant-specific weights - Added WeightUnit (enum, nullable) field for variant-specific units - Maintains backward compatibility with product-level weights TeleBot Integration: - Enhanced variant selection UI to display stock levels - Shows weight information with proper unit conversion (µg, g, oz, lb, ml, L) - Compact button format: "Medium (15 in stock, 350g)" - Real-time stock availability display Database Migrations: - 20250928014850_AddVariantCollectionsAndSalesLedger - 20250928155814_AddWeightToProductVariants Technical Changes: - Updated Product model to support VariantCollectionId and VariantsJson - Extended ProductService with variant collection operations - Enhanced OrderService to handle variant-specific pricing and weights - Updated LittleShop.Client DTOs to match server models - Added JavaScript dynamic variant form builder Files Modified: 15 Files Added: 17 Lines Changed: ~2000 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -10,26 +10,4 @@
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// Initialize Blazor Server
|
||||
window.addEventListener('DOMContentLoaded', function () {
|
||||
if (window.Blazor && window.Blazor.start) {
|
||||
console.log('Starting Blazor...');
|
||||
window.Blazor.start();
|
||||
} else {
|
||||
console.log('Blazor not available, attempting manual start...');
|
||||
// Fallback - load the blazor script if not already loaded
|
||||
if (!document.querySelector('script[src*="blazor.server.js"]')) {
|
||||
var script = document.createElement('script');
|
||||
script.src = '/_framework/blazor.server.js';
|
||||
script.onload = function() {
|
||||
if (window.Blazor) {
|
||||
window.Blazor.start();
|
||||
}
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
@{
|
||||
ViewData["Title"] = "Create Product";
|
||||
var categories = ViewData["Categories"] as IEnumerable<LittleShop.DTOs.CategoryDto>;
|
||||
var variantCollections = ViewData["VariantCollections"] as IEnumerable<LittleShop.DTOs.VariantCollectionDto>;
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
@@ -135,7 +136,49 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Variant Collection Section -->
|
||||
<hr class="my-4">
|
||||
<h5><i class="fas fa-layer-group"></i> Product Variants <small class="text-muted">(optional)</small></h5>
|
||||
<p class="text-muted">Add variant properties like Size, Color, or Flavor to this product.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="VariantCollectionId" class="form-label">Variant Collection</label>
|
||||
<select name="VariantCollectionId" id="VariantCollectionId" class="form-select">
|
||||
<option value="">No variant collection</option>
|
||||
@if (variantCollections != null)
|
||||
{
|
||||
@foreach (var collection in variantCollections)
|
||||
{
|
||||
<option value="@collection.Id" selected="@(Model?.VariantCollectionId == collection.Id)">@collection.Name</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<small class="form-text text-muted">Select a reusable variant template, or leave blank for custom variants</small>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Variant Fields (populated by JavaScript) -->
|
||||
<div id="dynamic-variant-fields" class="mb-3">
|
||||
<!-- JavaScript will populate this -->
|
||||
</div>
|
||||
|
||||
<!-- Hidden VariantsJson field for form submission -->
|
||||
<input type="hidden" name="VariantsJson" id="VariantsJson" value="@Model?.VariantsJson" />
|
||||
|
||||
<!-- Advanced JSON Editor (hidden by default) -->
|
||||
<div class="mb-3">
|
||||
<button type="button" id="toggle-advanced-variants" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-code"></i> Show Advanced JSON Editor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="advanced-variant-section" class="mb-3" style="display: none;">
|
||||
<label class="form-label">Advanced: Custom Variants JSON</label>
|
||||
<textarea class="form-control font-monospace" rows="4" placeholder='[{"Size":"M","Color":"Red"}]'
|
||||
onchange="document.getElementById('VariantsJson').value = this.value">@Model?.VariantsJson</textarea>
|
||||
<small class="form-text text-muted">For advanced users: Directly edit the JSON structure</small>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="@Url.Action("Index")" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Products
|
||||
@@ -171,6 +214,7 @@
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/product-variants.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const categorySelect = document.getElementById('CategoryId');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@{
|
||||
ViewData["Title"] = "Edit Product";
|
||||
var categories = ViewData["Categories"] as IEnumerable<LittleShop.DTOs.CategoryDto>;
|
||||
var variantCollections = ViewData["VariantCollections"] as IEnumerable<LittleShop.DTOs.VariantCollectionDto>;
|
||||
var productId = ViewData["ProductId"];
|
||||
var productPhotos = ViewData["ProductPhotos"] as IEnumerable<LittleShop.DTOs.ProductPhotoDto>;
|
||||
}
|
||||
@@ -92,6 +93,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Variants Collapsible Section -->
|
||||
<hr class="my-4">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header p-0">
|
||||
<button class="btn btn-link w-100 text-start d-flex justify-content-between align-items-center"
|
||||
type="button" data-bs-toggle="collapse" data-bs-target="#variantsCollapse"
|
||||
aria-expanded="false" aria-controls="variantsCollapse">
|
||||
<span>
|
||||
<i class="fas fa-layer-group me-2"></i>Product Variants
|
||||
<small class="text-muted ms-2">
|
||||
@{
|
||||
var currentVariantCollection = variantCollections?.FirstOrDefault(c => c.Id == Model?.VariantCollectionId);
|
||||
if (currentVariantCollection != null)
|
||||
{
|
||||
<span>Collection: @currentVariantCollection.Name</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>No variants configured</span>
|
||||
}
|
||||
}
|
||||
</small>
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transition-transform"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="variantsCollapse">
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Edit variant properties for this product.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="VariantCollectionId" class="form-label">Variant Collection</label>
|
||||
<select name="VariantCollectionId" id="VariantCollectionId" class="form-select">
|
||||
<option value="">No variant collection</option>
|
||||
@if (variantCollections != null)
|
||||
{
|
||||
@foreach (var collection in variantCollections)
|
||||
{
|
||||
<option value="@collection.Id" selected="@(Model?.VariantCollectionId == collection.Id)">@collection.Name</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<small class="form-text text-muted">Select a reusable variant template, or leave blank for custom variants</small>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Variant Fields (populated by JavaScript) -->
|
||||
<div id="dynamic-variant-fields" class="mb-3">
|
||||
<!-- JavaScript will populate this -->
|
||||
</div>
|
||||
|
||||
<!-- Hidden VariantsJson field for form submission -->
|
||||
<input type="hidden" name="VariantsJson" id="VariantsJson" value="@Model?.VariantsJson" />
|
||||
|
||||
<!-- Advanced JSON Editor (hidden by default) -->
|
||||
<div class="mb-3">
|
||||
<button type="button" id="toggle-advanced-variants" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-code"></i> Show Advanced JSON Editor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="advanced-variant-section" class="mb-3" style="display: none;">
|
||||
<label class="form-label">Advanced: Custom Variants JSON</label>
|
||||
<textarea class="form-control font-monospace" rows="4" placeholder='[{"Size":"M","Color":"Red"}]'
|
||||
onchange="document.getElementById('VariantsJson').value = this.value">@Model?.VariantsJson</textarea>
|
||||
<small class="form-text text-muted">For advanced users: Directly edit the JSON structure</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input name="IsActive" type="checkbox" class="form-check-input" checked="@(Model?.IsActive == true)" value="true" />
|
||||
@@ -99,7 +170,7 @@
|
||||
<label for="IsActive" class="form-check-label">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="@Url.Action("Index")" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Products
|
||||
@@ -112,69 +183,249 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Upload Section -->
|
||||
<!-- Product Photos Collapsible Section -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-camera"></i> Product Photos</h5>
|
||||
<div class="card-header p-0">
|
||||
<button class="btn btn-link w-100 text-start d-flex justify-content-between align-items-center"
|
||||
type="button" data-bs-toggle="collapse" data-bs-target="#photosCollapse"
|
||||
aria-expanded="false" aria-controls="photosCollapse">
|
||||
<span>
|
||||
<i class="fas fa-camera me-2"></i>Product Photos
|
||||
<small class="text-muted ms-2">
|
||||
@if (productPhotos != null && productPhotos.Any())
|
||||
{
|
||||
<span>@productPhotos.Count() photo(s)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>No photos uploaded</span>
|
||||
}
|
||||
</small>
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transition-transform"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="@Url.Action("UploadPhoto", new { id = productId })" enctype="multipart/form-data">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label for="file" class="form-label">Choose Photo</label>
|
||||
<input name="file" id="file" type="file" class="form-control" accept="image/*" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="altText" class="form-label">Alt Text</label>
|
||||
<input name="altText" id="altText" class="form-control" placeholder="Image description" />
|
||||
</div>
|
||||
|
||||
<!-- Collapsed Thumbnail Carousel Preview -->
|
||||
@if (productPhotos != null && productPhotos.Any())
|
||||
{
|
||||
<div class="collapse show" id="photoThumbnailsPreview">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex overflow-auto" style="gap: 8px;">
|
||||
@foreach (var photo in productPhotos.Take(5))
|
||||
{
|
||||
<img src="@photo.FilePath" class="flex-shrink-0"
|
||||
style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;"
|
||||
alt="@photo.AltText" title="@photo.FileName">
|
||||
}
|
||||
@if (productPhotos.Count() > 5)
|
||||
{
|
||||
<div class="flex-shrink-0 d-flex align-items-center justify-content-center bg-light text-muted"
|
||||
style="width: 50px; height: 50px; border-radius: 4px; font-size: 12px;">
|
||||
+@(productPhotos.Count() - 5)
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="upload-photo-btn">
|
||||
<i class="fas fa-upload"></i> Upload Photo
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (productPhotos != null && productPhotos.Any())
|
||||
{
|
||||
<hr>
|
||||
<h6><i class="fas fa-images"></i> Current Photos</h6>
|
||||
<div class="row">
|
||||
@foreach (var photo in productPhotos)
|
||||
{
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card">
|
||||
<img src="@photo.FilePath" class="card-img-top" style="height: 150px; object-fit: cover;" alt="@photo.AltText">
|
||||
<div class="card-body p-2">
|
||||
<small class="text-muted">@photo.FileName</small>
|
||||
<form method="post" action="@Url.Action("DeletePhoto", new { id = productId, photoId = photo.Id })" class="mt-1">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm w-100"
|
||||
onclick="return confirm('Delete this photo?')">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="collapse" id="photosCollapse">
|
||||
<div class="card-body">
|
||||
@if (productPhotos != null && productPhotos.Any())
|
||||
{
|
||||
<!-- Bootstrap Carousel -->
|
||||
<div id="photoCarousel" class="carousel slide mb-4" data-bs-ride="carousel">
|
||||
<div class="carousel-indicators">
|
||||
@for (int i = 0; i < productPhotos.Count(); i++)
|
||||
{
|
||||
<button type="button" data-bs-target="#photoCarousel" data-bs-slide-to="@i"
|
||||
class="@(i == 0 ? "active" : "")"
|
||||
aria-current="@(i == 0 ? "true" : "false")"
|
||||
aria-label="Slide @(i + 1)"></button>
|
||||
}
|
||||
</div>
|
||||
<div class="carousel-inner">
|
||||
@{int photoIndex = 0;}
|
||||
@foreach (var photo in productPhotos)
|
||||
{
|
||||
<div class="carousel-item @(photoIndex == 0 ? "active" : "")">
|
||||
<img src="@photo.FilePath" class="d-block w-100"
|
||||
style="height: 300px; object-fit: contain; background-color: #f8f9fa;"
|
||||
alt="@photo.AltText">
|
||||
<div class="carousel-caption d-none d-md-block bg-dark bg-opacity-75 rounded">
|
||||
<h5>@photo.FileName</h5>
|
||||
<p>@photo.AltText</p>
|
||||
</div>
|
||||
</div>
|
||||
photoIndex++;
|
||||
}
|
||||
</div>
|
||||
<button class="carousel-control-prev" type="button" data-bs-target="#photoCarousel" data-bs-slide="prev">
|
||||
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Previous</span>
|
||||
</button>
|
||||
<button class="carousel-control-next" type="button" data-bs-target="#photoCarousel" data-bs-slide="next">
|
||||
<span class="carousel-control-next-icon" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Next</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Photo Management Grid -->
|
||||
<h6><i class="fas fa-images"></i> Manage Photos</h6>
|
||||
<div class="row">
|
||||
@foreach (var photo in productPhotos)
|
||||
{
|
||||
<div class="col-md-6 col-lg-4 mb-3">
|
||||
<div class="card">
|
||||
<img src="@photo.FilePath" class="card-img-top" style="height: 150px; object-fit: cover;" alt="@photo.AltText">
|
||||
<div class="card-body p-2">
|
||||
<small class="text-muted">@photo.FileName</small>
|
||||
<form method="post" action="@Url.Action("DeletePhoto", new { id = productId, photoId = photo.Id })" class="mt-1">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm w-100"
|
||||
onclick="return confirm('Delete this photo?')">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<hr>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted text-center py-3 mb-4">
|
||||
<i class="fas fa-camera fa-2x mb-2"></i>
|
||||
<p>No photos uploaded yet. Upload your first photo below.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Upload Form -->
|
||||
<h6><i class="fas fa-upload"></i> Upload New Photo</h6>
|
||||
<form method="post" action="@Url.Action("UploadPhoto", new { id = productId })" enctype="multipart/form-data">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label for="file" class="form-label">Choose Photo</label>
|
||||
<input name="file" id="file" type="file" class="form-control" accept="image/*" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="mb-3">
|
||||
<label for="altText" class="form-label">Alt Text</label>
|
||||
<input name="altText" id="altText" class="form-control" placeholder="Image description" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="upload-photo-btn">
|
||||
<i class="fas fa-upload"></i> Upload Photo
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Reviews Collapsible Section -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header p-0">
|
||||
<button class="btn btn-link w-100 text-start d-flex justify-content-between align-items-center"
|
||||
type="button" data-bs-toggle="collapse" data-bs-target="#reviewsCollapse"
|
||||
aria-expanded="false" aria-controls="reviewsCollapse">
|
||||
<span>
|
||||
<i class="fas fa-star me-2"></i>Product Reviews
|
||||
<small class="text-muted ms-2">
|
||||
@{
|
||||
var productReviews = ViewData["ProductReviews"] as IEnumerable<dynamic>;
|
||||
if (productReviews != null && productReviews.Any())
|
||||
{
|
||||
<span>@productReviews.Count() review(s)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>No reviews yet</span>
|
||||
}
|
||||
}
|
||||
</small>
|
||||
</span>
|
||||
<i class="fas fa-chevron-down transition-transform"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="collapse" id="reviewsCollapse">
|
||||
<div class="card-body">
|
||||
@if (productReviews != null && productReviews.Any())
|
||||
{
|
||||
@foreach (var review in productReviews)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<!-- Star Rating Display -->
|
||||
<div class="me-2">
|
||||
@for (int i = 1; i <= 5; i++)
|
||||
{
|
||||
if (i <= (review.Rating ?? 0))
|
||||
{
|
||||
<i class="fas fa-star text-warning"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="far fa-star text-muted"></i>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<strong>@(review.CustomerName ?? "Anonymous Customer")</strong>
|
||||
</div>
|
||||
<small class="text-muted">@(review.CreatedAt?.ToString("MMM dd, yyyy") ?? "Date unknown")</small>
|
||||
</div>
|
||||
<span class="badge bg-primary">@(review.Rating ?? 0)/5</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(review.Comment?.ToString()))
|
||||
{
|
||||
<p class="mb-2">@review.Comment</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(review.OrderReference?.ToString()))
|
||||
{
|
||||
<small class="text-muted">
|
||||
<i class="fas fa-receipt"></i> Order: @review.OrderReference
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted text-center py-3">
|
||||
<i class="fas fa-camera fa-2x mb-2"></i>
|
||||
<p>No photos uploaded yet. Upload your first photo above.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Review Summary -->
|
||||
<div class="alert alert-info">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-6">
|
||||
<strong>Average Rating</strong><br>
|
||||
<span class="h4">@(productReviews.Average(r => r.Rating ?? 0).ToString("F1"))/5</span>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>Total Reviews</strong><br>
|
||||
<span class="h4">@productReviews.Count()</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted text-center py-4">
|
||||
<i class="fas fa-star fa-2x mb-2"></i>
|
||||
<p>No customer reviews yet.</p>
|
||||
<small>Reviews will appear here once customers start purchasing and reviewing this product.</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -195,33 +446,56 @@
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/product-variants.js"></script>
|
||||
<style>
|
||||
.transition-transform {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
[data-bs-toggle="collapse"][aria-expanded="true"] .transition-transform {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.btn-link:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: inherit;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Photo upload enhancement
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const fileInput = document.getElementById('file');
|
||||
const uploadBtn = document.getElementById('upload-photo-btn');
|
||||
const uploadForm = uploadBtn.closest('form');
|
||||
|
||||
|
||||
// Preview selected file
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
console.log('Photo selected:', file.name, file.size, 'bytes');
|
||||
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file (JPG, PNG, GIF, etc.)');
|
||||
fileInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert('File too large. Please select an image smaller than 5MB.');
|
||||
fileInput.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
uploadBtn.classList.remove('btn-primary');
|
||||
uploadBtn.classList.add('btn-success');
|
||||
uploadBtn.innerHTML = '<i class="fas fa-check"></i> Ready to Upload';
|
||||
@@ -231,7 +505,7 @@
|
||||
uploadBtn.innerHTML = '<i class="fas fa-upload"></i> Upload Photo';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Upload progress feedback
|
||||
uploadForm.addEventListener('submit', function(e) {
|
||||
if (!fileInput.files[0]) {
|
||||
@@ -239,10 +513,38 @@
|
||||
alert('Please select a photo first');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Uploading...';
|
||||
});
|
||||
|
||||
// Handle collapsible chevron rotation and thumbnail preview toggle
|
||||
const collapseElements = document.querySelectorAll('[data-bs-toggle="collapse"]');
|
||||
collapseElements.forEach(function(element) {
|
||||
element.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-bs-target');
|
||||
const chevron = this.querySelector('.transition-transform');
|
||||
|
||||
// Toggle chevron rotation
|
||||
if (chevron) {
|
||||
const isExpanded = this.getAttribute('aria-expanded') === 'true';
|
||||
this.setAttribute('aria-expanded', !isExpanded);
|
||||
}
|
||||
|
||||
// Handle photo thumbnail preview toggle
|
||||
if (targetId === '#photosCollapse') {
|
||||
const thumbnailPreview = document.getElementById('photoThumbnailsPreview');
|
||||
if (thumbnailPreview) {
|
||||
const isExpanded = this.getAttribute('aria-expanded') === 'false';
|
||||
if (isExpanded) {
|
||||
thumbnailPreview.classList.remove('show');
|
||||
} else {
|
||||
thumbnailPreview.classList.add('show');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -66,6 +66,11 @@
|
||||
<i class="fas fa-box"></i> Products
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="@Url.Action("Index", "VariantCollections", new { area = "Admin" })">
|
||||
<i class="fas fa-layer-group"></i> Variants
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="@Url.Action("Index", "Orders", new { area = "Admin" })">
|
||||
<i class="fas fa-shopping-cart"></i> Orders
|
||||
@@ -208,6 +213,12 @@
|
||||
Products
|
||||
</a>
|
||||
</li>
|
||||
<li class="settings-menu-item">
|
||||
<a href="@Url.Action("Index", "VariantCollections", new { area = "Admin" })" class="settings-menu-link">
|
||||
<i class="fas fa-layer-group"></i>
|
||||
Variant Collections
|
||||
</a>
|
||||
</li>
|
||||
<li class="settings-menu-item">
|
||||
<a href="@Url.Action("Index", "ShippingRates", new { area = "Admin" })" class="settings-menu-link">
|
||||
<i class="fas fa-truck"></i>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
@model LittleShop.DTOs.CreateVariantCollectionDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Variant Collection";
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-plus"></i> Create Variant Collection</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="@Url.Action("Index")" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Collections
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div asp-validation-summary="All" class="alert alert-danger" role="alert"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Name" class="form-label">Collection Name *</label>
|
||||
<input type="text" class="form-control" id="Name" name="Name" value="@Model.Name" required maxlength="100" placeholder="e.g., Mens Clothes, Jewelry Sizes">
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="PropertiesJson" class="form-label">Properties (JSON)</label>
|
||||
<textarea class="form-control font-monospace" id="PropertiesJson" name="PropertiesJson" rows="10" placeholder='[{"name":"Size","values":["S","M","L","XL"]},{"name":"Colour","values":null}]'>@Model.PropertiesJson</textarea>
|
||||
<div class="form-text">
|
||||
Define properties as JSON array. Each property has a "name" and optional "values" array.
|
||||
<br>If "values" is null, users can enter freeform text. If "values" is an array, users select from dropdown.
|
||||
<br><strong>Example:</strong> <code>[{"name":"Size","values":["S","M","L"]},{"name":"Colour","values":null}]</code>
|
||||
</div>
|
||||
<span asp-validation-for="PropertiesJson" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="border-top pt-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Create Collection
|
||||
</button>
|
||||
<a href="@Url.Action("Index")" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
}
|
||||
65
LittleShop/Areas/Admin/Views/VariantCollections/Edit.cshtml
Normal file
65
LittleShop/Areas/Admin/Views/VariantCollections/Edit.cshtml
Normal file
@@ -0,0 +1,65 @@
|
||||
@model LittleShop.DTOs.UpdateVariantCollectionDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Variant Collection";
|
||||
var collectionId = ViewData["CollectionId"];
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-edit"></i> Edit Variant Collection</h1>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="@Url.Action("Index")" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Collections
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="@Url.Action("Edit", new { id = collectionId })">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div asp-validation-summary="All" class="alert alert-danger" role="alert"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="Name" class="form-label">Collection Name</label>
|
||||
<input type="text" class="form-control" id="Name" name="Name" value="@Model.Name" maxlength="100" placeholder="e.g., Mens Clothes, Jewelry Sizes">
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="PropertiesJson" class="form-label">Properties (JSON)</label>
|
||||
<textarea class="form-control font-monospace" id="PropertiesJson" name="PropertiesJson" rows="10" placeholder='[{"name":"Size","values":["S","M","L","XL"]},{"name":"Colour","values":null}]'>@Model.PropertiesJson</textarea>
|
||||
<div class="form-text">
|
||||
Define properties as JSON array. Each property has a "name" and optional "values" array.
|
||||
<br>If "values" is null, users can enter freeform text. If "values" is an array, users select from dropdown.
|
||||
<br><strong>Example:</strong> <code>[{"name":"Size","values":["S","M","L"]},{"name":"Colour","values":null}]</code>
|
||||
</div>
|
||||
<span asp-validation-for="PropertiesJson" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="IsActive" name="IsActive" value="true" @(Model.IsActive == true ? "checked" : "")>
|
||||
<label class="form-check-label" for="IsActive">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-text">Inactive collections cannot be selected when editing products</div>
|
||||
</div>
|
||||
|
||||
<div class="border-top pt-3">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Save Changes
|
||||
</button>
|
||||
<a href="@Url.Action("Index")" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
@{await Html.RenderPartialAsync("_ValidationScriptsPartial");}
|
||||
}
|
||||
91
LittleShop/Areas/Admin/Views/VariantCollections/Index.cshtml
Normal file
91
LittleShop/Areas/Admin/Views/VariantCollections/Index.cshtml
Normal file
@@ -0,0 +1,91 @@
|
||||
@model IEnumerable<LittleShop.DTOs.VariantCollectionDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Variant Collections";
|
||||
}
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1><i class="fas fa-layer-group"></i> Variant Collections</h1>
|
||||
<p class="text-muted">Manage reusable variant templates for products</p>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<a href="@Url.Action("Create")" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Add Collection
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@if (Model.Any())
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Properties</th>
|
||||
<th>Created</th>
|
||||
<th>Updated</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var collection in Model)
|
||||
{
|
||||
<tr>
|
||||
<td><strong>@collection.Name</strong></td>
|
||||
<td>
|
||||
@if (collection.PropertiesJson != "[]" && !string.IsNullOrWhiteSpace(collection.PropertiesJson))
|
||||
{
|
||||
<code class="small">@collection.PropertiesJson.Substring(0, Math.Min(50, collection.PropertiesJson.Length))@(collection.PropertiesJson.Length > 50 ? "..." : "")</code>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">No properties</span>
|
||||
}
|
||||
</td>
|
||||
<td>@collection.CreatedAt.ToString("MMM dd, yyyy")</td>
|
||||
<td>@collection.UpdatedAt.ToString("MMM dd, yyyy")</td>
|
||||
<td>
|
||||
@if (collection.IsActive)
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">Inactive</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="@Url.Action("Edit", new { id = collection.Id })" class="btn btn-outline-primary">
|
||||
<i class="fas fa-edit"></i>
|
||||
</a>
|
||||
<form method="post" action="@Url.Action("Delete", new { id = collection.Id })" class="d-inline"
|
||||
onsubmit="return confirm('Are you sure you want to deactivate this collection?')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="fas fa-layer-group fa-3x text-muted mb-3"></i>
|
||||
<p class="text-muted">No variant collections found. <a href="@Url.Action("Create")">Create your first collection</a>.</p>
|
||||
<p class="small text-muted">Variant collections define reusable property templates (e.g., "Mens Clothes" with Size and Colour properties)</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user